Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6047ae44c4 | |||
| e246a32212 | |||
| 695037fb9c | |||
| 51b7316398 | |||
| edc9c6d4c3 | |||
| 7095e176b7 | |||
| 58f59476d8 |
114 changed files with 5016 additions and 9875 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
|||
*.pdf binary
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"""Passing the book key from an unlocked device to a new one (models.KeyShare).
|
||||
|
||||
The book key never reaches the server in the clear: the new device posts an ECDH P-256
|
||||
public key, an unlocked device of the same account answers with its own public key and
|
||||
the book key AES-GCM-encrypted under the shared secret. Both devices show a code made
|
||||
from the request's public key (static/js/app/33-unlock.js), so the user approves the
|
||||
right device. Requests run out after a few minutes; an answer is handed out once.
|
||||
|
||||
POST /accounts/keyshare/ {pub, device} -> {token}
|
||||
GET /accounts/keyshare/<token>/ -> {state, pub, iv, ct}
|
||||
GET /accounts/keyshare/pending/ -> {requests: [...]}
|
||||
POST /accounts/keyshare/<token>/answer/ {pub, iv, ct} | {decline: true}
|
||||
"""
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from .models import KeyShare
|
||||
|
||||
LIFETIME = timedelta(minutes=10)
|
||||
# Only requests whose device asked lately are offered: a closed tab drops out quickly.
|
||||
ALIVE = timedelta(seconds=20)
|
||||
MAX_OPEN = 5
|
||||
|
||||
|
||||
def _auth(request):
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
return None
|
||||
|
||||
|
||||
def _body(request):
|
||||
try:
|
||||
body = json.loads(request.body or b'{}')
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
|
||||
|
||||
def _b64(value, low, high):
|
||||
"""A base64 string whose decoded length lies in [low, high], else None."""
|
||||
if not isinstance(value, str) or len(value) > 4 * high:
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(value, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
return value if low <= len(raw) <= high else None
|
||||
|
||||
|
||||
def _purge(user):
|
||||
KeyShare.objects.filter(user=user, created_at__lt=timezone.now() - LIFETIME).delete()
|
||||
|
||||
|
||||
@require_http_methods(['POST'])
|
||||
def create(request):
|
||||
if (r := _auth(request)):
|
||||
return r
|
||||
body = _body(request)
|
||||
pub = _b64((body or {}).get('pub'), 65, 65) # uncompressed P-256 point
|
||||
if pub is None:
|
||||
return JsonResponse({'error': 'pub required'}, status=400)
|
||||
_purge(request.user)
|
||||
# A device that reloads asks anew: the oldest requests make room instead of blocking it.
|
||||
stale = KeyShare.objects.filter(user=request.user).order_by('-created_at').values_list('pk', flat=True)[MAX_OPEN - 1:]
|
||||
KeyShare.objects.filter(pk__in=list(stale)).delete()
|
||||
device = str(body.get('device') or '')[:100]
|
||||
share = KeyShare.objects.create(user=request.user, request_pub=pub, device=device,
|
||||
polled_at=timezone.now())
|
||||
return JsonResponse({'token': share.token})
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
def status(request, token):
|
||||
if (r := _auth(request)):
|
||||
return r
|
||||
_purge(request.user)
|
||||
share = KeyShare.objects.filter(user=request.user, token=token).first()
|
||||
if share is None:
|
||||
return JsonResponse({'state': 'expired'})
|
||||
if share.declined:
|
||||
share.delete()
|
||||
return JsonResponse({'state': 'declined'})
|
||||
if not share.answer_ct:
|
||||
share.polled_at = timezone.now()
|
||||
share.save(update_fields=['polled_at'])
|
||||
return JsonResponse({'state': 'pending'})
|
||||
data = {'state': 'answered', 'pub': share.answer_pub, 'iv': share.answer_iv, 'ct': share.answer_ct}
|
||||
share.delete() # handed out once
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
def pending(request):
|
||||
if (r := _auth(request)):
|
||||
return r
|
||||
_purge(request.user)
|
||||
open_ = KeyShare.objects.filter(user=request.user, declined=False, answer_ct='',
|
||||
polled_at__gte=timezone.now() - ALIVE).order_by('-created_at')
|
||||
return JsonResponse({'requests': [
|
||||
{'token': s.token, 'pub': s.request_pub, 'device': s.device,
|
||||
'created_at': s.created_at.isoformat()} for s in open_]})
|
||||
|
||||
|
||||
@require_http_methods(['POST'])
|
||||
def answer(request, token):
|
||||
if (r := _auth(request)):
|
||||
return r
|
||||
_purge(request.user)
|
||||
share = KeyShare.objects.filter(user=request.user, token=token).first()
|
||||
if share is None:
|
||||
return JsonResponse({'error': 'expired'}, status=404)
|
||||
if share.answer_ct or share.declined:
|
||||
return JsonResponse({'error': 'already answered'}, status=409)
|
||||
body = _body(request) or {}
|
||||
if body.get('decline'):
|
||||
share.declined = True
|
||||
share.answered_at = timezone.now()
|
||||
share.save(update_fields=['declined', 'answered_at'])
|
||||
return JsonResponse({'ok': True})
|
||||
pub = _b64(body.get('pub'), 65, 65)
|
||||
iv = _b64(body.get('iv'), 12, 12)
|
||||
ct = _b64(body.get('ct'), 32 + 16, 32 + 16) # 256-bit key + GCM tag
|
||||
if not (pub and iv and ct):
|
||||
return JsonResponse({'error': 'pub, iv and ct required'}, status=400)
|
||||
share.answer_pub, share.answer_iv, share.answer_ct = pub, iv, ct
|
||||
share.answered_at = timezone.now()
|
||||
share.save(update_fields=['answer_pub', 'answer_iv', 'answer_ct', 'answered_at'])
|
||||
return JsonResponse({'ok': True})
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
# Generated by Django 6.1 on 2026-11-01 13:35
|
||||
|
||||
import accounts.models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0007_uservault'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='KeyShare',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(default=accounts.models._generate_token, max_length=64, unique=True)),
|
||||
('device', models.CharField(blank=True, max_length=100)),
|
||||
('request_pub', models.TextField()),
|
||||
('answer_pub', models.TextField(blank=True)),
|
||||
('answer_iv', models.CharField(blank=True, max_length=64)),
|
||||
('answer_ct', models.TextField(blank=True)),
|
||||
('declined', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('polled_at', models.DateTimeField(blank=True, null=True)),
|
||||
('answered_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='key_shares', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OidcIdentity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('issuer', models.CharField(max_length=200)),
|
||||
('sub', models.CharField(max_length=255)),
|
||||
('username', models.CharField(blank=True, max_length=150)),
|
||||
('email', models.CharField(blank=True, max_length=254)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_login_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oidc_identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('issuer', 'sub')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -142,40 +142,3 @@ class ApiToken(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
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
165
accounts/oidc.py
|
|
@ -1,165 +0,0 @@
|
|||
"""Sign-in with an OpenID Connect provider (Authelia, docs/accounts.md).
|
||||
|
||||
An extra way in next to username + password: an account is linked once in the settings
|
||||
while signed in normally, after that the provider's login leads into it. Nobody gets a new
|
||||
account from the provider — an unlinked identity is turned away.
|
||||
|
||||
Authorization code flow with PKCE (S256) and a confidential client (client_secret_basic).
|
||||
The id_token is checked here (RS256 against the provider's JWKS, iss, aud, exp, nonce)
|
||||
with `cryptography`, so no OIDC library is needed.
|
||||
|
||||
The book key is not touched: it is derived from the diora password on the device. A device
|
||||
that signs in this way gets it from an unlocked device or by typing the password once
|
||||
(accounts/keyshare.py, static/js/app/33-unlock.js).
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from django.conf import settings
|
||||
|
||||
TIMEOUT = 10
|
||||
_CACHE_SECONDS = 3600
|
||||
_cache = {} # url -> (fetched_at, json)
|
||||
|
||||
|
||||
class OidcError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID and settings.OIDC_CLIENT_SECRET)
|
||||
|
||||
|
||||
def key_enabled() -> bool:
|
||||
return enabled() and bool(settings.OIDC_APP_CLIENT_ID)
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
|
||||
|
||||
|
||||
def _b64url(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
def _get_json(url: str, fresh: bool = False) -> dict:
|
||||
hit = _cache.get(url)
|
||||
if hit and not fresh and time.time() - hit[0] < _CACHE_SECONDS:
|
||||
return hit[1]
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
raise OidcError(f'{url} nicht erreichbar: {exc}') from exc
|
||||
_cache[url] = (time.time(), data)
|
||||
return data
|
||||
|
||||
|
||||
def discovery() -> dict:
|
||||
issuer = settings.OIDC_ISSUER.rstrip('/')
|
||||
return _get_json(issuer + '/.well-known/openid-configuration')
|
||||
|
||||
|
||||
def new_flow() -> dict:
|
||||
"""Random state, nonce and PKCE verifier for one sign-in (kept in the session)."""
|
||||
return {
|
||||
'state': secrets.token_urlsafe(24),
|
||||
'nonce': secrets.token_urlsafe(24),
|
||||
'verifier': secrets.token_urlsafe(48),
|
||||
}
|
||||
|
||||
|
||||
def authorize_url(flow: dict, redirect_uri: str) -> str:
|
||||
challenge = _b64url(hashlib.sha256(flow['verifier'].encode('ascii')).digest())
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': settings.OIDC_CLIENT_ID,
|
||||
'redirect_uri': redirect_uri,
|
||||
'scope': 'openid profile email groups',
|
||||
'state': flow['state'],
|
||||
'nonce': flow['nonce'],
|
||||
'code_challenge': challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
return discovery()['authorization_endpoint'] + '?' + urlencode(params)
|
||||
|
||||
|
||||
def exchange_code(code: str, flow: dict, redirect_uri: str) -> dict:
|
||||
"""Trade the code for tokens and return the checked id_token claims."""
|
||||
try:
|
||||
resp = requests.post(
|
||||
discovery()['token_endpoint'],
|
||||
data={
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': redirect_uri,
|
||||
'code_verifier': flow['verifier'],
|
||||
},
|
||||
auth=(settings.OIDC_CLIENT_ID, settings.OIDC_CLIENT_SECRET),
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise OidcError(f'Token-Abruf fehlgeschlagen: {exc}') from exc
|
||||
if resp.status_code != 200:
|
||||
raise OidcError(f'Token-Abruf abgelehnt ({resp.status_code})')
|
||||
try:
|
||||
id_token = resp.json()['id_token']
|
||||
except (ValueError, KeyError) as exc:
|
||||
raise OidcError('Antwort ohne id_token') from exc
|
||||
return verify_id_token(id_token, flow['nonce'])
|
||||
|
||||
|
||||
def _public_key(kid: str):
|
||||
jwks_uri = discovery()['jwks_uri']
|
||||
for fresh in (False, True): # a key rotation shows up as an unknown kid: fetch again once
|
||||
for jwk in _get_json(jwks_uri, fresh=fresh).get('keys', []):
|
||||
if jwk.get('kty') == 'RSA' and (not kid or jwk.get('kid') == kid):
|
||||
n = int.from_bytes(_b64url_decode(jwk['n']), 'big')
|
||||
e = int.from_bytes(_b64url_decode(jwk['e']), 'big')
|
||||
return rsa.RSAPublicNumbers(e, n).public_key()
|
||||
raise OidcError('Signaturschlüssel des Anbieters nicht gefunden')
|
||||
|
||||
|
||||
def verify_id_token(token: str, nonce, now: float = None, client_id: str = None) -> dict:
|
||||
"""Check signature and claims. `nonce` is the expected value, or a function that says
|
||||
whether the token's nonce is good (the apps' signed ones, oidc_views.app_login)."""
|
||||
try:
|
||||
header_b64, payload_b64, sig_b64 = token.split('.')
|
||||
header = json.loads(_b64url_decode(header_b64))
|
||||
claims = json.loads(_b64url_decode(payload_b64))
|
||||
signature = _b64url_decode(sig_b64)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise OidcError('id_token unlesbar') from exc
|
||||
if header.get('alg') != 'RS256':
|
||||
raise OidcError(f'nicht unterstützte Signatur {header.get("alg")!r} (erwartet RS256)')
|
||||
try:
|
||||
_public_key(header.get('kid', '')).verify(
|
||||
signature, f'{header_b64}.{payload_b64}'.encode('ascii'),
|
||||
padding.PKCS1v15(), hashes.SHA256())
|
||||
except InvalidSignature as exc:
|
||||
raise OidcError('Signatur des id_token ungültig') from exc
|
||||
|
||||
now = time.time() if now is None else now
|
||||
if claims.get('iss', '').rstrip('/') != settings.OIDC_ISSUER.rstrip('/'):
|
||||
raise OidcError('falscher Aussteller')
|
||||
aud = claims.get('aud')
|
||||
if (client_id or settings.OIDC_CLIENT_ID) not in (aud if isinstance(aud, list) else [aud]):
|
||||
raise OidcError('id_token ist nicht für diora')
|
||||
if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] < now - 60:
|
||||
raise OidcError('id_token abgelaufen')
|
||||
good = nonce(claims.get('nonce') or '') if callable(nonce) else (nonce and claims.get('nonce') == nonce)
|
||||
if not good:
|
||||
raise OidcError('nonce passt nicht')
|
||||
if not claims.get('sub'):
|
||||
raise OidcError('id_token ohne sub')
|
||||
return claims
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
"""Sign-in with Authelia and linking it to an existing account (accounts/oidc.py)."""
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core import signing
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from . import oidc
|
||||
from .models import ApiToken, OidcIdentity
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SESSION_KEY = 'oidc_flow'
|
||||
|
||||
|
||||
def _redirect_uri(request):
|
||||
return settings.OIDC_REDIRECT_URI or request.build_absolute_uri('/accounts/oidc/callback/')
|
||||
|
||||
|
||||
def _safe_next(request, value):
|
||||
if value and url_has_allowed_host_and_scheme(value, allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure()):
|
||||
return value
|
||||
return '/'
|
||||
|
||||
|
||||
def oidc_context(request):
|
||||
"""What the settings page shows about the provider."""
|
||||
return {
|
||||
'oidc_enabled': oidc.enabled(),
|
||||
'oidc_provider': settings.OIDC_PROVIDER_NAME,
|
||||
'oidc_identities': list(request.user.oidc_identities.all()) if request.user.is_authenticated else [],
|
||||
}
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
def oidc_start(request):
|
||||
"""Off to the provider. `?link=1` (signed in) links the account instead of signing in."""
|
||||
if not oidc.enabled():
|
||||
messages.error(request, 'Die Anmeldung über Authelia ist auf diesem Server nicht eingerichtet.')
|
||||
return redirect('login')
|
||||
link = request.GET.get('link') == '1'
|
||||
if link and not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
flow = oidc.new_flow()
|
||||
flow['link'] = link
|
||||
flow['next'] = _safe_next(request, request.GET.get('next', ''))
|
||||
try:
|
||||
url = oidc.authorize_url(flow, _redirect_uri(request))
|
||||
except oidc.OidcError as exc:
|
||||
log.warning('oidc start: %s', exc)
|
||||
messages.error(request, 'Authelia ist gerade nicht erreichbar.')
|
||||
return redirect('settings' if link else 'login')
|
||||
request.session[SESSION_KEY] = flow
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
def oidc_callback(request):
|
||||
flow = request.session.pop(SESSION_KEY, None)
|
||||
fail_to = 'login'
|
||||
if flow and flow.get('link'):
|
||||
fail_to = 'settings'
|
||||
if not flow or request.GET.get('state') != flow.get('state'):
|
||||
messages.error(request, 'Die Anmeldung über Authelia ist abgelaufen. Bitte noch einmal.')
|
||||
return redirect(fail_to)
|
||||
if request.GET.get('error'):
|
||||
messages.error(request, 'Authelia hat die Anmeldung abgebrochen.')
|
||||
return redirect(fail_to)
|
||||
code = request.GET.get('code', '')
|
||||
try:
|
||||
claims = oidc.exchange_code(code, flow, _redirect_uri(request))
|
||||
except oidc.OidcError as exc:
|
||||
log.warning('oidc callback: %s', exc)
|
||||
messages.error(request, 'Die Anmeldung über Authelia hat nicht geklappt.')
|
||||
return redirect(fail_to)
|
||||
|
||||
issuer = settings.OIDC_ISSUER.rstrip('/')
|
||||
sub = claims['sub']
|
||||
shown = {
|
||||
'username': (claims.get('preferred_username') or '')[:150],
|
||||
'email': (claims.get('email') or '')[:254],
|
||||
}
|
||||
identity = OidcIdentity.objects.filter(issuer=issuer, sub=sub).select_related('user').first()
|
||||
|
||||
if flow.get('link'):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
if identity and identity.user_id != request.user.id:
|
||||
messages.error(request, 'Dieses Authelia-Konto ist schon mit einem anderen diora-Konto verknüpft.')
|
||||
return redirect('settings')
|
||||
if identity is None:
|
||||
OidcIdentity.objects.create(user=request.user, issuer=issuer, sub=sub, **shown)
|
||||
else:
|
||||
OidcIdentity.objects.filter(pk=identity.pk).update(**shown)
|
||||
messages.success(request, f'Authelia-Konto „{shown["username"] or sub}“ verknüpft. '
|
||||
'Du kannst dich jetzt auch darüber anmelden.')
|
||||
if oidc.key_enabled(): # leave the book key with Authelia's help right away
|
||||
return redirect('/accounts/oidc/key/?next=/accounts/settings/')
|
||||
return redirect('settings')
|
||||
|
||||
if identity is None:
|
||||
messages.error(request, 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. '
|
||||
'Melde dich einmal mit Passwort an und verknüpfe es in den Einstellungen.')
|
||||
return redirect('login')
|
||||
if not identity.user.is_active:
|
||||
messages.error(request, 'Dieses diora-Konto ist gesperrt.')
|
||||
return redirect('login')
|
||||
OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now(), **shown)
|
||||
login(request, identity.user, backend='django.contrib.auth.backends.ModelBackend')
|
||||
# A password login brings the book key along, this one cannot: the device fetches it
|
||||
# (oidc_key below), else the page asks (static/js/app/33-unlock.js).
|
||||
next_url = flow.get('next') or '/'
|
||||
if oidc.key_enabled():
|
||||
from urllib.parse import urlencode
|
||||
return redirect('/accounts/oidc/key/?' + urlencode({'next': next_url}))
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['POST'])
|
||||
def oidc_unlink(request, pk):
|
||||
OidcIdentity.objects.filter(user=request.user, pk=pk).delete()
|
||||
messages.success(request, 'Verknüpfung mit Authelia gelöst.')
|
||||
return redirect('settings')
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['GET'])
|
||||
def oidc_key(request):
|
||||
"""The book key with the provider's help, without asking (docs/accounts.md).
|
||||
|
||||
Runs entirely on the device: it signs in once more as the public client
|
||||
(OIDC_APP_CLIENT_ID, PKCE, prompt=none — the provider session from just before makes it
|
||||
invisible), reads its `diora_key` claim from userinfo and with it opens the book key the
|
||||
server keeps wrapped (vault "authelia-key") — or, when the device has the key, wraps and
|
||||
leaves it there. The server never sees the claim; this view only hands out the page.
|
||||
The same page is the redirect target (`/accounts/oidc/key/callback/`)."""
|
||||
next_url = _safe_next(request, request.GET.get('next', ''))
|
||||
if not oidc.key_enabled():
|
||||
return redirect(next_url)
|
||||
try:
|
||||
disco = oidc.discovery()
|
||||
except oidc.OidcError as exc:
|
||||
log.warning('oidc key: %s', exc)
|
||||
return redirect(next_url)
|
||||
config = {
|
||||
'authorize': disco['authorization_endpoint'],
|
||||
'token': disco['token_endpoint'],
|
||||
'userinfo': disco.get('userinfo_endpoint', ''),
|
||||
'client_id': settings.OIDC_APP_CLIENT_ID,
|
||||
'redirect_uri': request.build_absolute_uri('/accounts/oidc/key/callback/')
|
||||
if not settings.OIDC_REDIRECT_URI
|
||||
else settings.OIDC_REDIRECT_URI.replace('/oidc/callback/', '/oidc/key/callback/'),
|
||||
'subs': [i.sub for i in request.user.oidc_identities.all()],
|
||||
'user_id': request.user.id,
|
||||
'next': next_url,
|
||||
}
|
||||
return render(request, 'accounts/oidc_key.html', {'config': config})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The apps (Android, desktop): sign in with Authelia as the public client themselves
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1. GET /accounts/oidc/app/ -> where Authelia is, the client id and a nonce
|
||||
# signed by this server (good for APP_NONCE_AGE).
|
||||
# 2. The app runs the code flow with PKCE in the system browser (redirect to its own
|
||||
# scheme or loopback), trades the code itself and reads `diora_key` from userinfo.
|
||||
# 3. POST /accounts/oidc/app/login/ {id_token} -> the account's API token, as the
|
||||
# password login (diora-android Auth.kt) hands it out.
|
||||
# The book key never passes here: the app opens the vault "authelia-key" itself.
|
||||
|
||||
APP_NONCE_SALT = 'diora.oidc.app'
|
||||
APP_NONCE_AGE = 600
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
def app_config(request):
|
||||
if not oidc.key_enabled():
|
||||
return JsonResponse({'enabled': False})
|
||||
try:
|
||||
disco = oidc.discovery()
|
||||
except oidc.OidcError as exc:
|
||||
log.warning('oidc app config: %s', exc)
|
||||
return JsonResponse({'enabled': False, 'error': 'provider unreachable'}, status=502)
|
||||
return JsonResponse({
|
||||
'enabled': True,
|
||||
'provider': settings.OIDC_PROVIDER_NAME,
|
||||
'issuer': settings.OIDC_ISSUER.rstrip('/'),
|
||||
'authorize': disco['authorization_endpoint'],
|
||||
'token': disco['token_endpoint'],
|
||||
'userinfo': disco.get('userinfo_endpoint', ''),
|
||||
'client_id': settings.OIDC_APP_CLIENT_ID,
|
||||
'scope': 'openid profile diora_key',
|
||||
'nonce': signing.dumps(secrets.token_urlsafe(16), salt=APP_NONCE_SALT),
|
||||
})
|
||||
|
||||
|
||||
def _app_nonce_ok(value):
|
||||
try:
|
||||
signing.loads(value, salt=APP_NONCE_SALT, max_age=APP_NONCE_AGE)
|
||||
return True
|
||||
except signing.BadSignature:
|
||||
return False
|
||||
|
||||
|
||||
@csrf_exempt # a native client without cookies; the signed id_token is the proof
|
||||
@require_http_methods(['POST'])
|
||||
def app_login(request):
|
||||
if not oidc.key_enabled():
|
||||
return JsonResponse({'error': 'not enabled'}, status=404)
|
||||
try:
|
||||
body = json.loads(request.body or b'{}')
|
||||
id_token = body['id_token']
|
||||
if not isinstance(id_token, str):
|
||||
raise TypeError
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return JsonResponse({'error': 'id_token required'}, status=400)
|
||||
try:
|
||||
claims = oidc.verify_id_token(id_token, _app_nonce_ok, client_id=settings.OIDC_APP_CLIENT_ID)
|
||||
except oidc.OidcError as exc:
|
||||
log.warning('oidc app login: %s', exc)
|
||||
return JsonResponse({'error': 'Die Anmeldung über Authelia war ungültig.'}, status=401)
|
||||
identity = OidcIdentity.objects.filter(
|
||||
issuer=settings.OIDC_ISSUER.rstrip('/'), sub=claims['sub']).select_related('user').first()
|
||||
if identity is None:
|
||||
return JsonResponse({'error': 'Dieses Authelia-Konto ist mit keinem diora-Konto verknüpft. '
|
||||
'Verknüpfe es einmal im Browser unter Einstellungen.'}, status=403)
|
||||
if not identity.user.is_active:
|
||||
return JsonResponse({'error': 'Dieses diora-Konto ist gesperrt.'}, status=403)
|
||||
OidcIdentity.objects.filter(pk=identity.pk).update(last_login_at=timezone.now())
|
||||
token, _ = ApiToken.objects.get_or_create(user=identity.user)
|
||||
return JsonResponse({'token': token.token, 'user_id': identity.user.id,
|
||||
'username': identity.user.username, 'sub': claims['sub']})
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import base64
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from . import oidc
|
||||
from .models import ApiToken, KeyShare, OidcIdentity
|
||||
|
||||
ISSUER = 'https://auth.example.org'
|
||||
PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
OTHER_PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
def _b64url(b):
|
||||
return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
|
||||
|
||||
|
||||
def _int_b64(i):
|
||||
return _b64url(i.to_bytes((i.bit_length() + 7) // 8, 'big'))
|
||||
|
||||
|
||||
def make_token(claims, key=PRIV, alg='RS256', kid='k1'):
|
||||
header = _b64url(json.dumps({'alg': alg, 'kid': kid, 'typ': 'JWT'}).encode())
|
||||
payload = _b64url(json.dumps(claims).encode())
|
||||
sig = key.sign(f'{header}.{payload}'.encode(), padding.PKCS1v15(), hashes.SHA256())
|
||||
return f'{header}.{payload}.{_b64url(sig)}'
|
||||
|
||||
|
||||
DISCOVERY = {
|
||||
'issuer': ISSUER,
|
||||
'authorization_endpoint': ISSUER + '/api/oidc/authorization',
|
||||
'token_endpoint': ISSUER + '/api/oidc/token',
|
||||
'jwks_uri': ISSUER + '/jwks.json',
|
||||
}
|
||||
pub = PRIV.public_key().public_numbers()
|
||||
JWKS = {'keys': [{'kty': 'RSA', 'kid': 'k1', 'n': _int_b64(pub.n), 'e': _int_b64(pub.e)}]}
|
||||
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, data, status=200):
|
||||
self._data, self.status_code = data, status
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise oidc.requests.HTTPError(str(self.status_code))
|
||||
|
||||
|
||||
def fake_get(url, timeout=None):
|
||||
return FakeResp(DISCOVERY if url.endswith('openid-configuration') else JWKS)
|
||||
|
||||
|
||||
@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret',
|
||||
OIDC_REDIRECT_URI='https://diora.test/accounts/oidc/callback/')
|
||||
class OidcFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
oidc._cache.clear()
|
||||
self.user = User.objects.create_user(username='crm', password='pw12345678')
|
||||
self.get = patch('accounts.oidc.requests.get', side_effect=fake_get)
|
||||
self.get.start()
|
||||
self.addCleanup(self.get.stop)
|
||||
|
||||
def _start(self, query=''):
|
||||
resp = self.client.get('/accounts/oidc/start/' + query)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
q = parse_qs(urlsplit(resp['Location']).query)
|
||||
self.assertEqual(q['code_challenge_method'], ['S256'])
|
||||
self.assertEqual(q['client_id'], ['diora'])
|
||||
return q['state'][0], q['nonce'][0]
|
||||
|
||||
def _callback(self, state, claims, token_status=200):
|
||||
token = make_token(claims)
|
||||
with patch('accounts.oidc.requests.post',
|
||||
return_value=FakeResp({'id_token': token}, token_status)) as post:
|
||||
resp = self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'c0de'})
|
||||
return resp, post
|
||||
|
||||
def _claims(self, nonce, **extra):
|
||||
c = {'iss': ISSUER, 'aud': ['diora'], 'sub': 'uuid-1', 'exp': time.time() + 300,
|
||||
'nonce': nonce, 'preferred_username': 'marwin', 'email': 'm@example.org'}
|
||||
c.update(extra)
|
||||
return c
|
||||
|
||||
def test_unlinked_identity_is_turned_away(self):
|
||||
state, nonce = self._start()
|
||||
resp, _ = self._callback(state, self._claims(nonce))
|
||||
self.assertEqual(resp['Location'], '/accounts/login/')
|
||||
self.assertNotIn('_auth_user_id', self.client.session)
|
||||
self.assertFalse(User.objects.exclude(pk=self.user.pk).exists())
|
||||
|
||||
def test_link_then_login(self):
|
||||
self.client.force_login(self.user)
|
||||
state, nonce = self._start('?link=1')
|
||||
resp, post = self._callback(state, self._claims(nonce))
|
||||
self.assertEqual(resp['Location'], '/accounts/settings/')
|
||||
ident = OidcIdentity.objects.get()
|
||||
self.assertEqual((ident.user, ident.sub, ident.username), (self.user, 'uuid-1', 'marwin'))
|
||||
# client_secret_basic + PKCE verifier
|
||||
self.assertEqual(post.call_args.kwargs['auth'], ('diora', 's3cret'))
|
||||
self.assertIn('code_verifier', post.call_args.kwargs['data'])
|
||||
|
||||
self.client.logout()
|
||||
state, nonce = self._start('?next=/books/')
|
||||
resp, _ = self._callback(state, self._claims(nonce))
|
||||
self.assertEqual(resp['Location'], '/books/')
|
||||
self.assertEqual(int(self.client.session['_auth_user_id']), self.user.pk)
|
||||
ident.refresh_from_db()
|
||||
self.assertIsNotNone(ident.last_login_at)
|
||||
|
||||
def test_next_to_other_host_is_dropped(self):
|
||||
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||
state, nonce = self._start('?next=https://evil.example/')
|
||||
resp, _ = self._callback(state, self._claims(nonce))
|
||||
self.assertEqual(resp['Location'], '/')
|
||||
|
||||
def test_identity_of_other_account_is_not_moved(self):
|
||||
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||
OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-1')
|
||||
self.client.force_login(self.user)
|
||||
state, nonce = self._start('?link=1')
|
||||
self._callback(state, self._claims(nonce))
|
||||
self.assertEqual(OidcIdentity.objects.get().user, other)
|
||||
|
||||
def test_wrong_state_nonce_audience_or_signature_fail(self):
|
||||
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||
cases = [
|
||||
('state', {}),
|
||||
('nonce', {'nonce': 'other'}),
|
||||
('aud', {'aud': 'someone-else'}),
|
||||
('iss', {'iss': 'https://evil.example'}),
|
||||
('exp', {'exp': time.time() - 3600}),
|
||||
]
|
||||
for name, extra in cases:
|
||||
with self.subTest(name):
|
||||
state, nonce = self._start()
|
||||
claims = {**self._claims(nonce), **extra}
|
||||
resp, _ = self._callback('wrong' if name == 'state' else state, claims)
|
||||
self.assertEqual(resp['Location'], '/accounts/login/')
|
||||
self.assertNotIn('_auth_user_id', self.client.session)
|
||||
state, nonce = self._start()
|
||||
with patch('accounts.oidc.requests.post',
|
||||
return_value=FakeResp({'id_token': make_token(self._claims(nonce), key=OTHER_PRIV)})):
|
||||
self.client.get('/accounts/oidc/callback/', {'state': state, 'code': 'x'})
|
||||
self.assertNotIn('_auth_user_id', self.client.session)
|
||||
|
||||
def test_state_is_single_use(self):
|
||||
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||
state, nonce = self._start()
|
||||
self._callback(state, self._claims(nonce))
|
||||
self.client.logout()
|
||||
resp, _ = self._callback(state, self._claims(nonce))
|
||||
self.assertNotIn('_auth_user_id', self.client.session)
|
||||
|
||||
def test_unlink(self):
|
||||
ident = OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||
foreign = OidcIdentity.objects.create(user=other, issuer=ISSUER, sub='uuid-2')
|
||||
self.client.force_login(self.user)
|
||||
self.client.post(f'/accounts/oidc/{foreign.pk}/unlink/')
|
||||
self.client.post(f'/accounts/oidc/{ident.pk}/unlink/')
|
||||
self.assertEqual(list(OidcIdentity.objects.all()), [foreign])
|
||||
|
||||
def test_with_app_client_login_and_link_go_by_the_key_page(self):
|
||||
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||
with override_settings(OIDC_APP_CLIENT_ID='diora-app'):
|
||||
state, nonce = self._start('?next=/books/')
|
||||
resp, _ = self._callback(state, self._claims(nonce))
|
||||
self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=%2Fbooks%2F')
|
||||
page = self.client.get(resp['Location'])
|
||||
self.assertContains(page, 'oidc-key-config')
|
||||
cfg = page.context['config']
|
||||
self.assertEqual(cfg['client_id'], 'diora-app')
|
||||
self.assertEqual(cfg['redirect_uri'], 'https://diora.test/accounts/oidc/key/callback/')
|
||||
self.assertEqual(cfg['subs'], ['uuid-1'])
|
||||
self.assertEqual(cfg['next'], '/books/')
|
||||
self.assertEqual(cfg['token'], DISCOVERY['token_endpoint'])
|
||||
self.assertEqual(self.client.get('/accounts/oidc/key/callback/?code=x&state=y').status_code, 200)
|
||||
|
||||
state, nonce = self._start('?link=1')
|
||||
resp, _ = self._callback(state, self._claims(nonce))
|
||||
self.assertEqual(resp['Location'], '/accounts/oidc/key/?next=/accounts/settings/')
|
||||
|
||||
def test_key_page_without_app_client_goes_straight_on(self):
|
||||
self.client.force_login(self.user)
|
||||
resp = self.client.get('/accounts/oidc/key/?next=/books/')
|
||||
self.assertEqual(resp['Location'], '/books/')
|
||||
|
||||
def test_login_page_shows_button_only_when_configured(self):
|
||||
self.assertContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden')
|
||||
with override_settings(OIDC_CLIENT_SECRET=''):
|
||||
self.assertNotContains(self.client.get('/accounts/login/'), 'Mit Authelia anmelden')
|
||||
|
||||
|
||||
@override_settings(OIDC_ISSUER=ISSUER, OIDC_CLIENT_ID='diora', OIDC_CLIENT_SECRET='s3cret',
|
||||
OIDC_APP_CLIENT_ID='diora-app')
|
||||
class OidcAppLoginTests(TestCase):
|
||||
def setUp(self):
|
||||
oidc._cache.clear()
|
||||
self.user = User.objects.create_user(username='crm', password='pw12345678')
|
||||
OidcIdentity.objects.create(user=self.user, issuer=ISSUER, sub='uuid-1')
|
||||
p = patch('accounts.oidc.requests.get', side_effect=fake_get)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def _nonce(self):
|
||||
cfg = self.client.get('/accounts/oidc/app/').json()
|
||||
self.assertEqual((cfg['client_id'], cfg['token']), ('diora-app', DISCOVERY['token_endpoint']))
|
||||
return cfg['nonce']
|
||||
|
||||
def _login(self, **claims):
|
||||
c = {'iss': ISSUER, 'aud': ['diora-app'], 'sub': 'uuid-1', 'exp': time.time() + 300,
|
||||
'nonce': self._nonce()}
|
||||
c.update(claims)
|
||||
return self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(c)}),
|
||||
content_type='application/json')
|
||||
|
||||
def test_linked_account_gets_its_existing_token(self):
|
||||
existing = ApiToken.objects.create(user=self.user)
|
||||
resp = self._login()
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json(), {'token': existing.token, 'user_id': self.user.id,
|
||||
'username': 'crm', 'sub': 'uuid-1'})
|
||||
|
||||
def test_token_is_made_when_there_is_none(self):
|
||||
resp = self._login()
|
||||
self.assertEqual(resp.json()['token'], ApiToken.objects.get(user=self.user).token)
|
||||
|
||||
def test_refused(self):
|
||||
for name, claims, code in [
|
||||
('unlinked', {'sub': 'other'}, 403),
|
||||
('web client token', {'aud': 'diora'}, 401),
|
||||
('own nonce', {'nonce': 'made-up'}, 401),
|
||||
('expired', {'exp': time.time() - 3600}, 401),
|
||||
]:
|
||||
with self.subTest(name):
|
||||
self.assertEqual(self._login(**claims).status_code, code)
|
||||
self.assertEqual(self.client.post('/accounts/oidc/app/login/', '{}',
|
||||
content_type='application/json').status_code, 400)
|
||||
|
||||
def test_old_nonce_is_refused(self):
|
||||
nonce = self._nonce()
|
||||
with patch('django.core.signing.time.time', return_value=time.time() + 3600):
|
||||
resp = self.client.post('/accounts/oidc/app/login/', json.dumps({'id_token': make_token(
|
||||
{'iss': ISSUER, 'aud': 'diora-app', 'sub': 'uuid-1', 'exp': time.time() + 7200, 'nonce': nonce})}),
|
||||
content_type='application/json')
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def test_off_without_app_client(self):
|
||||
with override_settings(OIDC_APP_CLIENT_ID=''):
|
||||
self.assertEqual(self.client.get('/accounts/oidc/app/').json(), {'enabled': False})
|
||||
|
||||
|
||||
PUB = base64.b64encode(b'\x04' + b'\x01' * 64).decode()
|
||||
|
||||
|
||||
class KeyShareTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='crm', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _post(self, url, body):
|
||||
return self.client.post(url, json.dumps(body), content_type='application/json')
|
||||
|
||||
def test_round_trip_hands_answer_out_once(self):
|
||||
token = self._post('/accounts/keyshare/', {'pub': PUB, 'device': 'Firefox auf Linux'}).json()['token']
|
||||
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'pending'})
|
||||
pending = self.client.get('/accounts/keyshare/pending/').json()['requests']
|
||||
self.assertEqual([(r['token'], r['pub'], r['device']) for r in pending], [(token, PUB, 'Firefox auf Linux')])
|
||||
|
||||
answer = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(),
|
||||
'ct': base64.b64encode(b'c' * 48).decode()}
|
||||
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 200)
|
||||
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', answer).status_code, 409)
|
||||
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||
got = self.client.get(f'/accounts/keyshare/{token}/').json()
|
||||
self.assertEqual(got, {'state': 'answered', **answer})
|
||||
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'expired'})
|
||||
|
||||
def test_requests_of_devices_that_stopped_asking_are_not_offered(self):
|
||||
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||
KeyShare.objects.update(polled_at=KeyShare.objects.get().created_at.replace(year=2000))
|
||||
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||
self.client.get(f'/accounts/keyshare/{token}/') # asks again
|
||||
self.assertEqual(len(self.client.get('/accounts/keyshare/pending/').json()['requests']), 1)
|
||||
|
||||
def test_decline(self):
|
||||
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||
self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True})
|
||||
self.assertEqual(self.client.get(f'/accounts/keyshare/{token}/').json(), {'state': 'declined'})
|
||||
|
||||
def test_bad_input_is_refused(self):
|
||||
self.assertEqual(self._post('/accounts/keyshare/', {'pub': 'nope'}).status_code, 400)
|
||||
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||
bad = {'pub': PUB, 'iv': base64.b64encode(b'i' * 12).decode(), 'ct': base64.b64encode(b'c' * 10).decode()}
|
||||
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', bad).status_code, 400)
|
||||
|
||||
def test_other_accounts_see_nothing(self):
|
||||
token = self._post('/accounts/keyshare/', {'pub': PUB}).json()['token']
|
||||
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||
self.client.force_login(other)
|
||||
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||
self.assertEqual(self._post(f'/accounts/keyshare/{token}/answer/', {'decline': True}).status_code, 404)
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get('/accounts/keyshare/pending/').status_code, 401)
|
||||
|
||||
def test_open_requests_are_limited_and_run_out(self):
|
||||
tokens = [self._post('/accounts/keyshare/', {'pub': PUB}).json()['token'] for _ in range(7)]
|
||||
self.assertEqual(KeyShare.objects.count(), 5)
|
||||
self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[0]}/').json(), {'state': 'expired'})
|
||||
self.assertEqual(self.client.get(f'/accounts/keyshare/{tokens[-1]}/').json(), {'state': 'pending'})
|
||||
KeyShare.objects.update(created_at=KeyShare.objects.first().created_at.replace(year=2000))
|
||||
self.assertEqual(self.client.get('/accounts/keyshare/pending/').json()['requests'], [])
|
||||
self.assertEqual(self._post('/accounts/keyshare/', {'pub': PUB}).status_code, 200)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from django.contrib.auth.views import LogoutView
|
||||
from django.urls import path
|
||||
from . import keyshare, oidc_views, views
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('register/', views.register, name='register'),
|
||||
|
|
@ -21,15 +21,4 @@ urlpatterns = [
|
|||
path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'),
|
||||
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
|
||||
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,7 +13,6 @@ from django.views.decorators.http import require_http_methods
|
|||
from radio import lastfm as lastfm_module
|
||||
|
||||
from .models import ApiToken, UserVault, WebDAVSource
|
||||
from .oidc_views import oidc_context
|
||||
from .webdav import WebDAVError, list_directory
|
||||
|
||||
User = get_user_model()
|
||||
|
|
@ -57,7 +56,7 @@ def login_view(request):
|
|||
else:
|
||||
form = AuthenticationForm()
|
||||
|
||||
return render(request, 'accounts/login.html', {'form': form, **oidc_context(request)})
|
||||
return render(request, 'accounts/login.html', {'form': form})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -79,7 +78,6 @@ def settings_view(request):
|
|||
'password_form': PasswordChangeForm(request.user),
|
||||
'api_token': getattr(request.user, 'api_token', None),
|
||||
'webdav_sources': request.user.webdav_sources.all(),
|
||||
**oidc_context(request),
|
||||
}
|
||||
return render(request, 'accounts/settings.html', context)
|
||||
|
||||
|
|
@ -219,7 +217,6 @@ def change_password(request):
|
|||
'password_form_open': True,
|
||||
'api_token': getattr(request.user, 'api_token', None),
|
||||
'webdav_sources': request.user.webdav_sources.all(),
|
||||
**oidc_context(request),
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
# diora's built-in Whisper (docs/transcription.md). Built on the server next to diora-prod:
|
||||
# docker compose build whisper
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir faster-whisper==1.2.1 fastapi==0.143.1 uvicorn==0.54.0 python-multipart==0.0.32
|
||||
WORKDIR /app
|
||||
COPY server.py .
|
||||
ENV WHISPER_CACHE=/models HF_HOME=/models/hf
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"""diora's built-in Whisper: a small OpenAI-compatible transcription server.
|
||||
|
||||
POST /v1/audio/transcriptions (multipart: file, model, language, prompt, response_format)
|
||||
returns {text} or, with response_format=verbose_json, {language, duration, text, segments}.
|
||||
The `model` field is ignored; WHISPER_MODEL decides. The model is loaded on the first request
|
||||
and freed again after WHISPER_IDLE seconds without work, so it holds no memory while unused.
|
||||
One transcription at a time; parallel requests wait (the server has few cores to share).
|
||||
See docs/transcription.md.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Windows with a GPU: where the CUDA libraries (cuBLAS, cuDNN) are, separated by ';'.
|
||||
for _d in filter(None, os.environ.get('WHISPER_DLL_DIRS', '').split(';')):
|
||||
os.add_dll_directory(_d)
|
||||
os.environ['PATH'] = _d + os.pathsep + os.environ['PATH']
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile # noqa: E402
|
||||
from faster_whisper import WhisperModel # noqa: E402
|
||||
|
||||
MODEL = os.environ.get('WHISPER_MODEL', 'large-v3-turbo')
|
||||
THREADS = int(os.environ.get('WHISPER_THREADS', '4'))
|
||||
DEVICE = os.environ.get('WHISPER_DEVICE', 'cpu') # cpu or cuda
|
||||
COMPUTE = os.environ.get('WHISPER_COMPUTE', 'int8') # int8_float16 on a GPU
|
||||
IDLE = int(os.environ.get('WHISPER_IDLE', '600'))
|
||||
CACHE = os.environ.get('WHISPER_CACHE', '/models')
|
||||
|
||||
app = FastAPI()
|
||||
work = threading.Lock() # one transcription at a time
|
||||
state = {'model': None, 'last': 0.0}
|
||||
|
||||
|
||||
def model():
|
||||
if state['model'] is None:
|
||||
state['model'] = WhisperModel(MODEL, device=DEVICE, compute_type=COMPUTE, cpu_threads=THREADS,
|
||||
download_root=CACHE)
|
||||
return state['model']
|
||||
|
||||
|
||||
def unloader():
|
||||
while True:
|
||||
time.sleep(30)
|
||||
if state['model'] is not None and time.time() - state['last'] > IDLE and work.acquire(blocking=False):
|
||||
try:
|
||||
state['model'] = None
|
||||
finally:
|
||||
work.release()
|
||||
|
||||
|
||||
threading.Thread(target=unloader, daemon=True).start()
|
||||
|
||||
|
||||
def decode(data: bytes) -> np.ndarray:
|
||||
p = subprocess.run(['ffmpeg', '-nostdin', '-loglevel', 'error', '-i', 'pipe:0', '-ac', '1', '-ar', '16000',
|
||||
'-f', 'f32le', 'pipe:1'], input=data, capture_output=True)
|
||||
if p.returncode != 0:
|
||||
raise HTTPException(400, 'not audio: ' + p.stderr.decode('utf-8', 'replace')[-200:])
|
||||
return np.frombuffer(p.stdout, np.float32)
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
def health():
|
||||
return {'ok': True, 'model': MODEL, 'device': DEVICE, 'loaded': state['model'] is not None}
|
||||
|
||||
|
||||
@app.post('/v1/audio/transcriptions')
|
||||
def transcriptions(file: UploadFile = File(...), language: str = Form(''), prompt: str = Form(''),
|
||||
response_format: str = Form('json')):
|
||||
audio = decode(file.file.read())
|
||||
t0 = time.time()
|
||||
with work:
|
||||
segs, info = model().transcribe(audio, language=language or None, initial_prompt=prompt or None,
|
||||
beam_size=5, vad_filter=True)
|
||||
segs = [{'id': i, 'start': round(s.start, 2), 'end': round(s.end, 2), 'text': s.text}
|
||||
for i, s in enumerate(segs)]
|
||||
state['last'] = time.time()
|
||||
dur = len(audio) / 16000
|
||||
print(f'{dur:.0f} s audio in {time.time() - t0:.0f} s', flush=True)
|
||||
text = ''.join(s['text'] for s in segs).strip()
|
||||
if response_format == 'verbose_json':
|
||||
return {'task': 'transcribe', 'language': info.language, 'duration': dur, 'text': text, 'segments': segs}
|
||||
return {'text': text}
|
||||
|
|
@ -84,9 +84,6 @@ DATABASES = {
|
|||
}
|
||||
}
|
||||
|
||||
# The built-in Whisper instance (OpenAI-compatible, base such as http://whisper:8000/v1);
|
||||
# empty = none (docs/transcription.md).
|
||||
WHISPER_URL = os.environ.get('DIORA_WHISPER_URL', '').strip().rstrip('/')
|
||||
PODCAST_MAX_EPISODES_PER_FEED = int(os.environ.get('PODCAST_MAX_EPISODES_PER_FEED', '200'))
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
|
|
@ -141,17 +138,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||
|
||||
LOGIN_URL = '/accounts/login/'
|
||||
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 = '/'
|
||||
|
||||
# Last.fm
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
# Anmeldung und Bücher-Schlüssel
|
||||
|
||||
## Zwei Wege hinein
|
||||
|
||||
**Benutzername und Passwort** (`accounts/views.py` `login_view`): Das Login-Formular leitet im
|
||||
Browser aus dem Passwort den Bücher-Schlüssel ab (PBKDF2-SHA256, Salt `diora:<username>`,
|
||||
200 000 Runden) und legt ihn bis zur nächsten Seite in `localStorage['diora_pending_enc_key']`
|
||||
ab. Die App-Seite übernimmt ihn als `diora_enc_key_<userId>` (`32-crypto.js`, `99-init.js`).
|
||||
|
||||
**Authelia (OpenID Connect)** (`accounts/oidc.py`, `accounts/oidc_views.py`): als zusätzlicher
|
||||
Weg, die Anmeldung mit Passwort bleibt. Der Ablauf ist Authorization Code mit PKCE (S256) und
|
||||
einem vertraulichen Client (`client_secret_basic`). Das id_token wird hier geprüft (RS256 gegen
|
||||
die JWKS des Anbieters, `iss`, `aud`, `exp`, `nonce`), dafür reicht `cryptography`.
|
||||
|
||||
- Ein Konto wird **einmal verknüpft**: in den Einstellungen „Mit Authelia verknüpfen“, während
|
||||
man normal angemeldet ist (`/accounts/oidc/start/?link=1`). Gespeichert wird `OidcIdentity`
|
||||
(`issuer`, `sub`; Name und Mail nur zur Anzeige). Danach führt „Mit Authelia anmelden“ auf der
|
||||
Login-Seite in dieses Konto.
|
||||
- Eine nicht verknüpfte Authelia-Identität wird abgewiesen. Es entstehen **keine neuen
|
||||
Konten** über Authelia, und es wird auch nicht über Name oder Mail automatisch verknüpft.
|
||||
- Wer hinein darf, regelt Authelia (Policy `mitglieder` = Gruppen `admins` oder `freunde`).
|
||||
- Einstellungen per Umgebung: `DIORA_OIDC_ISSUER`, `DIORA_OIDC_CLIENT_ID`,
|
||||
`DIORA_OIDC_CLIENT_SECRET`, `DIORA_OIDC_REDIRECT_URI` (fest, weil hinter dem Proxy kein
|
||||
https erkannt wird), optional `DIORA_OIDC_PROVIDER_NAME`. Fehlt eins der ersten drei, gibt es
|
||||
keinen Knopf. Auf prod stehen sie in `~/docker-compose/diora-prod/.env` (Strato), das
|
||||
Gegenstück liegt auf Hetzner in `/opt/authelia/oidc-clients.env`.
|
||||
- Redirect-URI im Authelia-Client: `https://diora.creamfresh.xyz/accounts/oidc/callback/`.
|
||||
|
||||
## Der Schlüssel nach einer Authelia-Anmeldung
|
||||
|
||||
### Normalfall: über Authelia, ohne Nachfrage
|
||||
|
||||
Authelia hält je Nutzer ein geheimes Attribut `diora_key` (32 Byte, base64). Es geht nur an den
|
||||
öffentlichen Client `diora-app` (`DIORA_OIDC_APP_CLIENT_ID`, PKCE, Scope `diora_key`) und nur
|
||||
über userinfo, nicht im id_token. Nach jedem Authelia-Login (und nach dem Verknüpfen) führt
|
||||
diora auf `/accounts/oidc/key/` (`templates/accounts/oidc_key.html`). Die Seite meldet sich
|
||||
**im Browser** noch einmal mit `prompt=none` als `diora-app` an, unsichtbar, weil die
|
||||
Authelia-Sitzung noch besteht, und holt per CORS Token und userinfo. Daraus leitet sie mit
|
||||
HKDF-SHA256 (Salt = `sub`, info `diora authelia key v1`) einen AES-GCM-Schlüssel ab:
|
||||
|
||||
- Hat das Gerät den Bücher-Schlüssel (und passt er zu einem Buch), wird er damit verschlüsselt
|
||||
und im Vault `authelia-key` abgelegt, bei jedem Authelia-Login neu. So heilt ein geänderter
|
||||
`diora_key` von selbst.
|
||||
- Hat es keinen, öffnet sie den Vault-Eintrag damit und legt den Schlüssel ab.
|
||||
- Es zählt nur das Authelia-Konto, das mit diesem diora-Konto verknüpft ist (`sub`).
|
||||
|
||||
Der diora-Server sieht `diora_key` nie. **Grenze, ehrlich:** Wer Authelia (Hetzner-Root) oder
|
||||
dein Authelia-Konto (Passwort, Mail-Reset) übernimmt, kommt damit an die Bücher. Wer Strato
|
||||
übernimmt, kann im Browser ohnehin manipuliertes JS ausliefern. Geschützt ist gegen geleakte
|
||||
Daten **einer** Seite (diora-DB/Backup ohne Authelia-Nutzerdatei oder umgekehrt). Geht
|
||||
`diora_key` verloren, sind die Bücher nicht weg: Der Schlüssel kommt weiter aus dem Passwort.
|
||||
Entschieden 2026-11-01 vom Nutzer (Komfort vor stärkerer Trennung).
|
||||
|
||||
### Notweg: Code oder Passwort
|
||||
|
||||
Ohne Passwort gibt es keinen Schlüssel. Deshalb erzeugt `getOrCreateEncKey()` **nie mehr
|
||||
selbst einen neuen Schlüssel**: Früher machte es einen zufälligen, wenn keiner da war, und damit
|
||||
waren die vorhandenen Bücher auf diesem Gerät verloren. Jetzt fragt es `dioraUnlock()`
|
||||
(`static/js/app/33-unlock.js`). Es gibt zwei Wege:
|
||||
|
||||
1. **Von einem entsperrten Gerät übergeben** (`accounts/keyshare.py`, Modell `KeyShare`). Das
|
||||
neue Gerät schickt einen ECDH-P-256-Public-Key und zeigt einen 6-stelligen Code, der aus
|
||||
diesem Key berechnet wird. Entsperrte Geräte fragen alle 15 s
|
||||
`/accounts/keyshare/pending/` ab und zeigen denselben Code mit „Freigeben/Ablehnen“. Die
|
||||
Freigabe verschlüsselt den Bücher-Schlüssel mit AES-GCM unter dem gemeinsamen Geheimnis
|
||||
(HKDF-SHA256, info `diora keyshare v1`). Der Server sieht nur Public Keys und Chiffrat und
|
||||
gibt die Antwort genau einmal heraus. Anträge laufen nach 10 min ab. Angeboten werden nur
|
||||
solche, deren Gerät in den letzten 20 s nachgefragt hat, und je Konto höchstens 5, die
|
||||
ältesten weichen.
|
||||
2. **Das diora-Passwort einmal eingeben**: dieselbe Ableitung wie beim Login.
|
||||
|
||||
In beiden Fällen wird der Schlüssel an einem Buch geprüft, bevor er gespeichert wird. Mit
|
||||
„Später“ bleiben Bücher gesperrt, und die Bibliothek zeigt einen Knopf „Entsperren“. Die
|
||||
Einstellungsseite erzeugt ebenfalls keinen Schlüssel mehr.
|
||||
|
||||
## Apps
|
||||
|
||||
Android und Desktop melden sich bisher nur mit Passwort an (eigene Login-Seiten in
|
||||
`diora-android/web-native/login.html` und `diora-desktop/src/web/login.js`, die einen
|
||||
API-Token holen). Mit Authelia melden sie sich selbst als öffentlicher Client `diora-app` an:
|
||||
|
||||
1. `GET /accounts/oidc/app/` sagt, wo Authelia ist, gibt die Client-ID und eine vom Server
|
||||
signierte nonce (10 min gültig).
|
||||
2. Die App führt den Code-Flow mit PKCE im Systembrowser aus. Redirect:
|
||||
`xyz.creamfresh.diora:/oauth` bzw. `xyz.creamfresh.diora.reader:/oauth` (Android),
|
||||
Loopback `http://127.0.0.1:<port>/oauth` (Desktop, jeder Port). Den Code tauscht sie selbst
|
||||
und liest `diora_key` aus userinfo.
|
||||
3. `POST /accounts/oidc/app/login/ {id_token}` prüft das Token (`aud` = `diora-app`, nonce) und
|
||||
gibt den API-Token des verknüpften Kontos heraus (den vorhandenen, kein neuer).
|
||||
4. Die App holt mit dem Token `/accounts/vault/authelia-key/` und öffnet den Bücher-Schlüssel
|
||||
wie die Webseite (HKDF, Salt `sub`). Ist dort noch nichts, fragt sie nach dem diora-Passwort.
|
||||
|
|
@ -29,8 +29,6 @@ kept by the server as a blob it can't read:
|
|||
the key can't be read: the settings ask for it again. Not on diora Reader (e-readers).
|
||||
- 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).
|
||||
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)
|
||||
|
||||
|
|
@ -43,8 +41,6 @@ kept by the server as a blob it can't read:
|
|||
(`output_config.format` json_schema), effort `low` by default, streamed, `fallbacks: "default"`
|
||||
(beta `server-side-fallback-2026-07-01`) for declined requests; refusals and over-long
|
||||
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
|
||||
changes until the user ticks and presses "Übernehmen".
|
||||
- `DioraAI.settings()`, `withKey(fn)`, `progress()`, `showError()`, `toast()`; the sheets close
|
||||
|
|
@ -57,6 +53,5 @@ kept by the server as a blob it can't read:
|
|||
| Bücher, Zeitschriften | ✦ in the library's head (`static/js/app/55-library-tools.js`) | per book: id, title, author, folders, series, magazine mark; the existing folders | folders to add (existing first; new ones only for ≥ 3 books), series with volume, cleaned title/author where broken, magazine mark |
|
||||
| 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 |
|
||||
| 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).
|
||||
|
|
|
|||
|
|
@ -182,12 +182,3 @@ Listening without committing: one button plays the Strom like a station.
|
|||
- Web host (`static/js/podcasts-web.js`, `DioraPodcastHost`): `podcastLaterAdd(json episodes)`,
|
||||
`podcastLaterRemove(json ids)`, `podcastListsSwitch(on)`, `podcastStreamSeen()` (keeps the
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,262 +0,0 @@
|
|||
# Smarte Transkription: Transkript und Artikel zu einer Folge
|
||||
|
||||
Decided with the user on 2026-11-09. Plan and order of work:
|
||||
diora-android `plans/smarte-transkription.md`.
|
||||
|
||||
## What it is for
|
||||
|
||||
Sometimes the shownotes don't tell whether an episode still has something to teach. Then the
|
||||
user presses a button and, a few hours later at most, gets a readable article written from the
|
||||
speakers' own standpoint. It is meant to let them judge the episode for themselves, so it keeps
|
||||
arguments, reasons and evidence and does not just summarise them. The article sits on the
|
||||
episode and in Blogs.
|
||||
|
||||
Nothing happens on its own. The server never transcribes ahead, by cron or on subscribing; the
|
||||
only way to start a transcription is the `POST` below, sent when the user presses a button.
|
||||
|
||||
## Who does what
|
||||
|
||||
The **server** transcribes. It already downloads and decodes episodes for skipping silence
|
||||
(`podcasts/silence.py`), and browsers and the WebView can't fetch podcast audio (CORS). It cuts
|
||||
the audio at pauses and sends the pieces in parallel to a Whisper instance with an
|
||||
OpenAI-compatible API (`POST /v1/audio/transcriptions`): the built-in one by default (`DIORA_WHISPER_URL`),
|
||||
or any instance the user enters. YouTube episodes are
|
||||
not transcribed: the server never fetches their sound (docs/youtube.md).
|
||||
|
||||
The **device** writes the article with the user's own key (docs/ai.md). The key never reaches the
|
||||
server. It is one LLM call of a few minutes. If diora is open when the transcript is done, it
|
||||
happens right away; otherwise the next time diora is opened on any of the user's devices. The
|
||||
server only remembers that an article was wanted.
|
||||
|
||||
Transcript and article are stored in plain text on the server, like all podcast and feed data.
|
||||
|
||||
## Flow
|
||||
|
||||
1. In the episode the user presses "Transkript erstellen" (the Transkript tab) or
|
||||
"✦ Artikel erstellen" / "✦ Transkribieren und Artikel erstellen". The second sends
|
||||
`article: true`. With a transcript already there (from the feed or an earlier run) only the
|
||||
article is written.
|
||||
2. The server works through the episode (minutes to hours, no GPU on Strato). The UI shows
|
||||
"Transkribiere 7/38 …" while it is open.
|
||||
3. Done: a notice for `GET /feeds/notifications/`. Android's BackgroundSync and the desktop show
|
||||
"Transkript fertig: <Folge>", with "Artikel wird beim Öffnen geschrieben" when one is wanted.
|
||||
Tapping it opens diora at the episode.
|
||||
4. A device with a key (`static/js/podcast-article.js`) picks up the open wishes, reserves one,
|
||||
writes the article and saves it. It then says "Artikel fertig" (on Android as a system
|
||||
notification through the bridge, `DioraNative.notify(title, text, target)` with target
|
||||
`episode:<id>` or `item:<id>`; elsewhere a toast) and opens it on a tap.
|
||||
5. The article is the first tab of the episode ("Artikel") and an item of the favourite blog
|
||||
"Podcast-Artikel", linking back to the episode.
|
||||
|
||||
## API
|
||||
|
||||
**Settings.** `GET /podcasts/transcription/settings/` returns
|
||||
`{engine: "builtin"|"custom", url, model, language, parallel, has_token, builtin_available}`.
|
||||
`POST` takes the same fields and `token` (write-only; `""` removes it). The token is kept
|
||||
encrypted with Fernet like `feeds.SiteLogin.secret`. `language` is `""` for automatic or an ISO
|
||||
code (`de`). `parallel` is 1 to 8, default 3. `builtin_available` is whether `DIORA_WHISPER_URL`
|
||||
is set. `url` is the instance's base, e.g. `https://whisper.example.org/v1`; the server appends
|
||||
`/audio/transcriptions`.
|
||||
|
||||
An empty `model` is sent as `whisper-1`. The built-in URL is read into `settings.WHISPER_URL` from
|
||||
`DIORA_WHISPER_URL`. A custom engine needs a `url`; the token is only sent to custom instances.
|
||||
|
||||
`POST /podcasts/transcription/test/` sends one second of silence to the configured instance and
|
||||
returns `{ok, model, error}`. A custom URL goes through the same SSRF check as chapters
|
||||
(`podcasts/chapters.py` `_open`): no private, loopback or link-local addresses. Only the built-in
|
||||
URL from `DIORA_WHISPER_URL` is exempt.
|
||||
|
||||
**Job.** `POST /podcasts/episodes/<id>/transcribe/` with `{article: true|false}` starts a job, or
|
||||
updates `want_article` on a running one. With a transcript already there it returns
|
||||
`{state: "done"}` straight away (and keeps `want_article`). `GET` on the same address returns
|
||||
`{state, chunks_done, chunks_total, error, want_article, started_at, finished_at}`, with `state`
|
||||
one of `none`, `queued`, `downloading`, `transcribing`, `done`, `error`. `DELETE` cancels a
|
||||
queued or running job (its row is deleted, so `GET` then says `none`; a finished one stays). The server runs one job at a time; the others wait as `queued`. A YouTube
|
||||
episode returns 400 `{error: "youtube"}`, an episode over 800 MB 400 `{error: "too large"}`. The size is asked with a `HEAD`
|
||||
request (`Content-Length`; if that fails the job starts anyway) and checked again while
|
||||
downloading, where it ends the job with the error `too large`. `article: true` is only ever
|
||||
raised, never lowered, by a repeated `POST`; a `POST` after an `error` starts a new job (at the
|
||||
back of the queue). Jobs run in id order, which is why the notice below can use ids as its cursor.
|
||||
A feed transcript already there gives a job that is `done` with no `data`: it only holds the wish
|
||||
for an article and causes no `transcripts` notice.
|
||||
|
||||
**Transcript.** The result is kept on the job (`TranscriptionJob.data`) in the format of
|
||||
`EpisodeTranscript.data`: `{segments: [{start, end, speaker, text}], source: {type: "whisper",
|
||||
model, language}}`. `speaker` is `""`. It is kept apart from `EpisodeTranscript`, which is a cache
|
||||
of the feed's transcripts and is rewritten when the feed changes them.
|
||||
`GET /podcasts/episodes/<id>/transcript/` returns the feed's transcript when the feed has a
|
||||
usable one, else the Whisper one, else 404 as before. `episode_detail` adds
|
||||
`transcript_origin` (`"feed"`, `"whisper"` or `null`), `has_transcript` is true for both, and it
|
||||
adds `article_item_id` (or `null`) and `transcription` (the job's state as above, or `null`).
|
||||
|
||||
**Article.** `POST /podcasts/episodes/<id>/article/` takes `{title, summary, body_html, model,
|
||||
kind}` and returns `{item_id}`. The server cleans `body_html` with `feeds/fetch.py clean_html`.
|
||||
It creates the user's source "Podcast-Artikel" when missing (`kind` blog, `fav` true,
|
||||
`synthetic` true, `url` `diora://podcast-artikel`) and adds a `feeds.Item` there, or replaces the
|
||||
episode's earlier one. Item fields: the episode's link (else its audio URL) as `url`, the feed's
|
||||
author as `author`, the episode's artwork as `image_url`, now as `published` (so it is on top of
|
||||
the blog stream under "heute"), `words` counted. Saving clears the wish. `DELETE` on the same
|
||||
address removes the article and its item. `GET /feeds/items/<id>/` and `_item_data` carry
|
||||
`episode_id` for such items.
|
||||
`body_html` may contain `href="#t=<seconds>"` links; `clean_html` keeps exactly this fragment form
|
||||
(`podcasts/shownotes.py` `TIME_LINK`) and no other `#` links. Validation: `title` 1 to 300
|
||||
characters, `body_html` non-empty after cleaning and at most 1,000,000 characters, `summary` at most
|
||||
2000, `model` at most 100, `kind` at most 20 (400 otherwise). The item has `content_full` true (no
|
||||
page is ever fetched) and, as the reader's furniture filter would strip repeated "▶ ab" blocks,
|
||||
`GET /feeds/items/<id>/` skips that filter for synthetic sources. The endpoint is
|
||||
`podcasts/article.py`.
|
||||
|
||||
Synthetic sources are skipped by the feed refresh (`refresh_newsfeeds`, `refresh_now`) and by the
|
||||
pruning of old blog items (`feeds/refresh.py`). They can be hidden or have their bell turned off
|
||||
like any blog; they can't be refreshed, renamed to another URL or turned into news. `refresh.refresh()` itself drops them, the OPML export leaves them out, and `_source_data` carries `synthetic`.
|
||||
|
||||
**Open wishes.** `GET /podcasts/transcription/pending/` returns
|
||||
`[{episode_id, title, podcast, duration_seconds}]`: the user's episodes whose transcript is done,
|
||||
that want an article, have none and aren't reserved. `POST
|
||||
/podcasts/episodes/<id>/article/claim/` reserves one for 15 minutes for the calling device
|
||||
(200), or answers 409 when another device holds it, so phone and desktop don't both write it.
|
||||
`DELETE` on the claim releases it (the device gave up, e.g. no key or an error the user saw).
|
||||
The device names itself with `device` in the JSON body (or, if that is missing, the
|
||||
`X-Diora-Device` header): a short stable name such as `android`, `desktop` or `browser`, at most 60
|
||||
characters; a claim without one is 400. The same device claiming again renews its 15 minutes. A
|
||||
claim on an episode with no open wish (no job done, no article wanted, or an article already
|
||||
there) is 404. `DELETE` by a device that does not hold the claim is 409.
|
||||
|
||||
**Notice.** `GET /feeds/notifications/` additionally takes `t_after` and returns
|
||||
`transcripts: [{job_id, episode_id, title, podcast, want_article}]` (jobs done after that id) and
|
||||
`t_last`. Without `t_after` (a device's first time) the list is empty and only `t_last` comes,
|
||||
like `after`/`last`. Devices that don't know the fields ignore them.
|
||||
|
||||
## Transcribing (`podcasts/transcribe.py`)
|
||||
|
||||
Download with the same limits as `silence.py`, decode with imageio-ffmpeg to 16 kHz mono. Cut
|
||||
into pieces of about eight minutes; each cut moves to the longest pause within ±30 seconds
|
||||
(`silence.py`'s detection), so no word is cut. Pieces go out as FLAC to the built-in instance
|
||||
and as Opus 32 kbit/s to custom ones, to keep uploads small.
|
||||
|
||||
Each piece is sent with `model`, `language` (when set), `response_format=verbose_json` and a
|
||||
`prompt` of podcast title, episode title and author names, so Whisper spells names right. Pieces
|
||||
run in a thread pool of `parallel` workers; each is retried up to three times with a growing
|
||||
pause; progress is written after each. Segment times are shifted by the piece's start; the
|
||||
segments are joined and smoothed with `transcripts.merge`. A job runs in a daemon thread, like
|
||||
`silence.ensure()`; one per server. A job found `downloading` or `transcribing` after a restart
|
||||
is started again. This happens when the first
|
||||
request (`GET`/`POST` on `/transcribe/` or `/transcription/pending/`) after a start reaches the server.
|
||||
Cancelling deletes the job's row; the worker notices at the latest after five seconds, stops
|
||||
sending pieces and removes its temporary files (they live in a temporary directory that is always
|
||||
removed). Only the worker's main thread writes to the database. The settings used are those of the
|
||||
episode's owner, read when the job starts. A refusal by the instance with a 4xx status other than
|
||||
408 and 429 is not retried and ends the job with `Whisper: HTTP <code>`.
|
||||
|
||||
## The article (`static/js/podcast-article.js`)
|
||||
|
||||
Input: the transcript as plain text, one line per segment group of about 30 seconds,
|
||||
`[mm:ss] Sprecher: text` (or `[mm:ss] text` without speakers), plus the episode's metadata:
|
||||
podcast title, author, episode title, date, duration, the shownotes as plain text and the
|
||||
chapters. Over about 120,000 tokens (more than about nine hours) the device stops with a clear
|
||||
message for now. A five-hour episode is about 70,000 tokens.
|
||||
|
||||
`DioraAI.ask({system, prompt, schema, effort: "medium", maxTokens: 32000, onProgress, signal})`.
|
||||
`onProgress(words)` reports the words written so far while streaming ("Schreibe Artikel … 2.300
|
||||
Wörter"); `signal` aborts. Both are optional and new in ai.js; the existing callers don't change.
|
||||
|
||||
Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "summary", "kind", "speakers", "sections"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"kind": {"type": "string", "enum": ["Interview", "Gespräch", "Monolog", "Diskussion"]},
|
||||
"speakers": {"type": "array", "items": {"type": "string"}},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["heading", "start", "body_markdown"],
|
||||
"properties": {
|
||||
"heading": {"type": "string"},
|
||||
"start": {"type": "integer"},
|
||||
"body_markdown": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The HTML: the summary as a lead paragraph, then per section an `<h2>`, below it a link
|
||||
"▶ ab 12:34" (`<a href="#t=754">`; `clean_html` keeps fragment links, and feeds.js plays the
|
||||
item's `episode_id` from there when such a link is tapped) and the body. Markdown is limited to paragraphs, emphasis, lists and block
|
||||
quotes, converted on the device; the server cleans the result anyway.
|
||||
|
||||
System prompt (the user's words decide the perspective; keep its substance when changing it):
|
||||
|
||||
```text
|
||||
You turn the transcript of a podcast episode into an article that reads as if the people
|
||||
speaking had written it themselves instead of recording the episode.
|
||||
|
||||
The reader wants to decide whether this episode still has something to teach them. They do not
|
||||
want a summary; they want to understand what is argued here well enough to judge it for
|
||||
themselves. Write for that reader.
|
||||
|
||||
Perspective. Write from the speakers' standpoint and state their positions directly, as their
|
||||
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
|
||||
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
|
||||
is one person speaking, write as that person argues, in the third person or impersonal voice,
|
||||
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
|
||||
soften what is said.
|
||||
|
||||
Conversations. Interviews, conversations and discussions are condensed into their core theses;
|
||||
the form of question and answer disappears. Where people hold different positions, keep them
|
||||
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
|
||||
minutes. Questions from a host matter only where they shape the argument. When the transcript
|
||||
has no speaker names, infer them from the context (introductions, the shownotes, how people
|
||||
address each other); if you can't, do not invent names.
|
||||
|
||||
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
|
||||
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
|
||||
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
|
||||
title. Make clear what is new, distinctive or surprising in the episode and where it restates
|
||||
what is commonly said, by how you weight and order it, without grading it.
|
||||
|
||||
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
|
||||
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
|
||||
lead nowhere.
|
||||
|
||||
Form. Write in the language of the episode. `title`: a title for the article, not the episode
|
||||
title repeated. `summary`: three to six sentences up front that state the central thesis and how
|
||||
the episode gets there. `sections`: as many as the content needs (usually four to ten), each
|
||||
with a heading that says something (not "Einleitung"), `start` = the second in the episode where
|
||||
its content begins (from the [mm:ss] marks), and `body_markdown` in plain paragraphs; lists only
|
||||
where the speakers list things themselves. Follow the order of the episode unless a different
|
||||
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
|
||||
roughly a tenth of the transcript, at most about 5,000 words.
|
||||
|
||||
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
|
||||
using the metadata, and don't quote garbled passages.
|
||||
```
|
||||
|
||||
Prompt (user turn): the metadata block, then `Transkript:` and the text.
|
||||
|
||||
## The built-in instance (since 2026-11-09)
|
||||
|
||||
Whisper does not fit on the Strato server: with about 2 GB of memory free, large-v3-turbo needed
|
||||
more than 2.2 GB and even `small` broke a 1.2 GB limit, and earlyoom then killed other services.
|
||||
So the built-in instance runs on the user's PC (multi11, GTX 1660 Ti) and diora-prod reaches it over
|
||||
the tailnet: `DIORA_WHISPER_URL=http://100.64.0.3:8766/v1` in `~/docker-compose/diora-prod/.env`.
|
||||
It is `deploy/whisper/server.py` (a small OpenAI-compatible server around faster-whisper,
|
||||
large-v3-turbo, int8_float16 on CUDA; one request at a time; the model is loaded on demand and freed
|
||||
after 10 idle minutes), started at logon from `F:\diora-whisper` (start.cmd via start-hidden.vbs,
|
||||
shortcut in the user's Startup folder; log `F:\diora-whisper\server.log`), listening only on the
|
||||
tailnet address. One hour of speech takes about five minutes.
|
||||
|
||||
When the PC is off, a job does not fail: the server checks the instance before downloading, and a
|
||||
connection error (there or while pieces are out) puts the job back into the queue with
|
||||
`error = "Whisper-Instanz nicht erreichbar, neuer Versuch um HH:MM"`, tried again every ten
|
||||
minutes (`WAIT_RETRY`) for up to two days after the button was pressed (`WAIT_MAX`). Only the
|
||||
built-in instance waits like this; a custom instance's connection errors fail the job as before.
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
"""A small PDF with the cases the PDF reader has to get right (e2e and by hand).
|
||||
|
||||
python e2e/fixtures/make_test_pdf.py e2e/fixtures/test.pdf
|
||||
|
||||
12 pages: A4 portrait text pages, one A5 page and one A4 landscape page (mixed
|
||||
sizes), page 7 nearly empty (only a page number: the smart crop must not zoom
|
||||
it to infinity), an outline (table of contents) with three entries, internal
|
||||
links from page 1 to pages 5 and 9 and one external link, a coloured picture on
|
||||
page 2 (it must stay as it is in the reader's colours) and page labels like a
|
||||
book's: i, ii for the first two pages, then 1, 2, 3 … Plain PDF 1.4, Helvetica,
|
||||
no dependencies.
|
||||
"""
|
||||
import sys
|
||||
|
||||
A4 = (595, 842)
|
||||
A5 = (420, 595)
|
||||
A4L = (842, 595)
|
||||
|
||||
WORDS = ('Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. '
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. ').split()
|
||||
|
||||
|
||||
def esc(t):
|
||||
return t.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
|
||||
|
||||
|
||||
def text_lines(n, words_per_line=11):
|
||||
out, i = [], 0
|
||||
for _ in range(n):
|
||||
out.append(' '.join(WORDS[(i + k) % len(WORDS)] for k in range(words_per_line)))
|
||||
i += words_per_line
|
||||
return out
|
||||
|
||||
|
||||
def page_content(no, size):
|
||||
w, h = size
|
||||
ops = ['BT', '/F1 22 Tf', f'50 {h - 70} Td', f'(Kapitel {no} - Seite {no}) Tj', 'ET']
|
||||
if no == 7:
|
||||
# Nearly empty: just the number at the bottom.
|
||||
return '\n'.join(['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET'])
|
||||
y = h - 110
|
||||
lines = text_lines(28 if h > 700 else 18, 11 if w > 500 else 8)
|
||||
ops += ['BT', '/F1 11 Tf', f'50 {y} Td', '14 TL']
|
||||
for ln in lines:
|
||||
ops.append(f'({esc(ln)}) Tj T*')
|
||||
ops.append('ET')
|
||||
if no == 2:
|
||||
ops += ['q', f'200 0 0 120 {w - 260} 60 cm', '/Im1 Do', 'Q']
|
||||
if no == 1:
|
||||
ops += ['BT', '/F1 12 Tf', f'50 {y - 30 - 14 * len(lines)} Td',
|
||||
'(Link zu Seite 5) Tj', '0 -20 Td', '(Link zu Seite 9) Tj', '0 -20 Td', '(Externer Link: example.org) Tj', 'ET']
|
||||
ops += ['BT', '/F1 10 Tf', f'{w / 2 - 10:.0f} 30 Td', f'({no}) Tj', 'ET']
|
||||
return '\n'.join(ops)
|
||||
|
||||
|
||||
def build(path):
|
||||
sizes = [A4] * 12
|
||||
sizes[3] = A5 # page 4
|
||||
sizes[8] = A4L # page 9
|
||||
objs = [] # index = object number - 1
|
||||
|
||||
def add(body):
|
||||
objs.append(body)
|
||||
return len(objs)
|
||||
|
||||
catalog = add(None)
|
||||
pages_obj = add(None)
|
||||
font = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>')
|
||||
# A picture of four colours (2 x 2 pixels, drawn large).
|
||||
pixels = b'FF0000 00A000 0000FF FFD700>'
|
||||
image = add(b'<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB '
|
||||
b'/BitsPerComponent 8 /Filter /ASCIIHexDecode /Length %d >>\nstream\n' % len(pixels)
|
||||
+ pixels + b'\nendstream')
|
||||
page_ids = []
|
||||
for i, size in enumerate(sizes):
|
||||
content = page_content(i + 1, size).encode('cp1252')
|
||||
cid = add(b'<< /Length %d >>\nstream\n' % len(content) + content + b'\nendstream')
|
||||
pid = add(None)
|
||||
page_ids.append((pid, cid, size))
|
||||
# links on page 1 (placed where the text is: bottom part of the text block)
|
||||
p1_h = sizes[0][1]
|
||||
base_y = p1_h - 110 - 30 - 14 * 28
|
||||
|
||||
def link_to(page_no, y):
|
||||
return add(f'<< /Type /Annot /Subtype /Link /Rect [48 {y - 4} 220 {y + 14}] /Border [0 0 0] '
|
||||
f'/Dest [{page_ids[page_no - 1][0]} 0 R /XYZ 0 {sizes[page_no - 1][1]} null] >>')
|
||||
annots = [link_to(5, base_y), link_to(9, base_y - 20),
|
||||
add(f'<< /Type /Annot /Subtype /Link /Rect [48 {base_y - 44} 300 {base_y - 26}] /Border [0 0 0] '
|
||||
f'/A << /S /URI /URI (https://example.org/) >> >>')]
|
||||
for n, (pid, cid, (w, h)) in enumerate(page_ids):
|
||||
extra = f' /Annots [{" ".join(f"{a} 0 R" for a in annots)}]' if n == 0 else ''
|
||||
xobj = f' /XObject << /Im1 {image} 0 R >>' if n == 1 else ''
|
||||
objs[pid - 1] = (f'<< /Type /Page /Parent {pages_obj} 0 R /MediaBox [0 0 {w} {h}] '
|
||||
f'/Resources << /Font << /F1 {font} 0 R >>{xobj} >> /Contents {cid} 0 R{extra} >>')
|
||||
objs[pages_obj - 1] = f'<< /Type /Pages /Kids [{" ".join(f"{p} 0 R" for p, _, _ in page_ids)}] /Count {len(page_ids)} >>'
|
||||
# outline: three entries
|
||||
outline = add(None)
|
||||
entries = [('Anfang', 1), ('Querformat', 9), ('Fast leer', 7)]
|
||||
ids = [add(None) for _ in entries]
|
||||
for k, ((title, page), oid) in enumerate(zip(entries, ids)):
|
||||
nav = ''
|
||||
if k > 0:
|
||||
nav += f' /Prev {ids[k - 1]} 0 R'
|
||||
if k < len(ids) - 1:
|
||||
nav += f' /Next {ids[k + 1]} 0 R'
|
||||
objs[oid - 1] = (f'<< /Title ({esc(title)}) /Parent {outline} 0 R{nav} '
|
||||
f'/Dest [{page_ids[page - 1][0]} 0 R /XYZ 0 {sizes[page - 1][1]} null] >>')
|
||||
objs[outline - 1] = f'<< /Type /Outlines /First {ids[0]} 0 R /Last {ids[-1]} 0 R /Count {len(ids)} >>'
|
||||
objs[catalog - 1] = (f'<< /Type /Catalog /Pages {pages_obj} 0 R /Outlines {outline} 0 R /PageMode /UseOutlines '
|
||||
f'/PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >>')
|
||||
info = add('<< /Title (Testdokument PDF) /Author (diora Test) >>')
|
||||
|
||||
out = bytearray(b'%PDF-1.4\n%\xe2\xe3\xcf\xd3\n')
|
||||
offsets = []
|
||||
for i, body in enumerate(objs):
|
||||
offsets.append(len(out))
|
||||
data = body if isinstance(body, bytes) else body.encode('cp1252')
|
||||
out += b'%d 0 obj\n' % (i + 1) + data + b'\nendobj\n'
|
||||
xref = len(out)
|
||||
out += b'xref\n0 %d\n0000000000 65535 f \n' % (len(objs) + 1)
|
||||
for off in offsets:
|
||||
out += b'%010d 00000 n \n' % off
|
||||
out += (b'trailer\n<< /Size %d /Root %d 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n'
|
||||
% (len(objs) + 1, catalog, info, xref))
|
||||
open(path, 'wb').write(out)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
build(sys.argv[1] if len(sys.argv) > 1 else 'test.pdf')
|
||||
|
|
@ -1,652 +0,0 @@
|
|||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R /Outlines 32 0 R /PageMode /UseOutlines /PageLabels << /Nums [0 << /S /r >> 2 << /S /D >>] >> >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [6 0 R 8 0 R 10 0 R 12 0 R 14 0 R 16 0 R 18 0 R 20 0 R 22 0 R 24 0 R 26 0 R 28 0 R] /Count 12 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 28 >>
|
||||
stream
|
||||
FF0000 00A000 0000FF FFD700>
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Length 2463 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 1 - Seite 1) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 12 Tf
|
||||
50 310 Td
|
||||
(Link zu Seite 5) Tj
|
||||
0 -20 Td
|
||||
(Link zu Seite 9) Tj
|
||||
0 -20 Td
|
||||
(Externer Link: example.org) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(1) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R /Annots [29 0 R 30 0 R 31 0 R] >>
|
||||
endobj
|
||||
7 0 obj
|
||||
<< /Length 2379 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 2 - Seite 2) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
q
|
||||
200 0 0 120 335 60 cm
|
||||
/Im1 Do
|
||||
Q
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(2) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
8 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents 7 0 R >>
|
||||
endobj
|
||||
9 0 obj
|
||||
<< /Length 2345 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 3 - Seite 3) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(3) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
10 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 9 0 R >>
|
||||
endobj
|
||||
11 0 obj
|
||||
<< /Length 1196 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 525 Td
|
||||
(Kapitel 4 - Seite 4) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 485 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
200 30 Td
|
||||
(4) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
12 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 11 0 R >>
|
||||
endobj
|
||||
13 0 obj
|
||||
<< /Length 2345 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 5 - Seite 5) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(5) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
14 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 13 0 R >>
|
||||
endobj
|
||||
15 0 obj
|
||||
<< /Length 2345 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 6 - Seite 6) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(6) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 15 0 R >>
|
||||
endobj
|
||||
17 0 obj
|
||||
<< /Length 32 >>
|
||||
stream
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(7) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
18 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 17 0 R >>
|
||||
endobj
|
||||
19 0 obj
|
||||
<< /Length 2345 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 8 - Seite 8) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(8) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
20 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 19 0 R >>
|
||||
endobj
|
||||
21 0 obj
|
||||
<< /Length 1547 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 525 Td
|
||||
(Kapitel 9 - Seite 9) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 485 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
411 30 Td
|
||||
(9) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 21 0 R >>
|
||||
endobj
|
||||
23 0 obj
|
||||
<< /Length 2348 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 10 - Seite 10) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(10) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 23 0 R >>
|
||||
endobj
|
||||
25 0 obj
|
||||
<< /Length 2348 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 11 - Seite 11) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(11) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 25 0 R >>
|
||||
endobj
|
||||
27 0 obj
|
||||
<< /Length 2348 >>
|
||||
stream
|
||||
BT
|
||||
/F1 22 Tf
|
||||
50 772 Td
|
||||
(Kapitel 12 - Seite 12) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 11 Tf
|
||||
50 732 Td
|
||||
14 TL
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
(etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed) Tj T*
|
||||
(do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und) Tj T*
|
||||
(Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit,) Tj T*
|
||||
(sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche) Tj T*
|
||||
(und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur adipiscing) Tj T*
|
||||
(elit, sed do eiusmod tempor. Die Seite trägt Text, damit Auswahl,) Tj T*
|
||||
(Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet, consectetur) Tj T*
|
||||
(adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text, damit) Tj T*
|
||||
(Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit amet,) Tj T*
|
||||
(consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt Text,) Tj T*
|
||||
(damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor sit) Tj T*
|
||||
(amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite trägt) Tj T*
|
||||
(Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum dolor) Tj T*
|
||||
(sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die Seite) Tj T*
|
||||
(trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem ipsum) Tj T*
|
||||
(dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. Die) Tj T*
|
||||
(Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden. Lorem) Tj T*
|
||||
(ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.) Tj T*
|
||||
(Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas finden.) Tj T*
|
||||
(Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod) Tj T*
|
||||
(tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen etwas) Tj T*
|
||||
(finden. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do) Tj T*
|
||||
(eiusmod tempor. Die Seite trägt Text, damit Auswahl, Suche und Vorlesen) Tj T*
|
||||
ET
|
||||
BT
|
||||
/F1 10 Tf
|
||||
288 30 Td
|
||||
(12) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents 27 0 R >>
|
||||
endobj
|
||||
29 0 obj
|
||||
<< /Type /Annot /Subtype /Link /Rect [48 306 220 324] /Border [0 0 0] /Dest [14 0 R /XYZ 0 842 null] >>
|
||||
endobj
|
||||
30 0 obj
|
||||
<< /Type /Annot /Subtype /Link /Rect [48 286 220 304] /Border [0 0 0] /Dest [22 0 R /XYZ 0 595 null] >>
|
||||
endobj
|
||||
31 0 obj
|
||||
<< /Type /Annot /Subtype /Link /Rect [48 266 300 284] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>
|
||||
endobj
|
||||
32 0 obj
|
||||
<< /Type /Outlines /First 33 0 R /Last 35 0 R /Count 3 >>
|
||||
endobj
|
||||
33 0 obj
|
||||
<< /Title (Anfang) /Parent 32 0 R /Next 34 0 R /Dest [6 0 R /XYZ 0 842 null] >>
|
||||
endobj
|
||||
34 0 obj
|
||||
<< /Title (Querformat) /Parent 32 0 R /Prev 33 0 R /Next 35 0 R /Dest [22 0 R /XYZ 0 595 null] >>
|
||||
endobj
|
||||
35 0 obj
|
||||
<< /Title (Fast leer) /Parent 32 0 R /Prev 34 0 R /Dest [18 0 R /XYZ 0 842 null] >>
|
||||
endobj
|
||||
36 0 obj
|
||||
<< /Title (Testdokument PDF) /Author (diora Test) >>
|
||||
endobj
|
||||
xref
|
||||
0 37
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000158 00000 n
|
||||
0000000292 00000 n
|
||||
0000000389 00000 n
|
||||
0000000584 00000 n
|
||||
0000003099 00000 n
|
||||
0000003256 00000 n
|
||||
0000005687 00000 n
|
||||
0000005839 00000 n
|
||||
0000008236 00000 n
|
||||
0000008363 00000 n
|
||||
0000009612 00000 n
|
||||
0000009740 00000 n
|
||||
0000012138 00000 n
|
||||
0000012266 00000 n
|
||||
0000014664 00000 n
|
||||
0000014792 00000 n
|
||||
0000014875 00000 n
|
||||
0000015003 00000 n
|
||||
0000017401 00000 n
|
||||
0000017529 00000 n
|
||||
0000019129 00000 n
|
||||
0000019257 00000 n
|
||||
0000021658 00000 n
|
||||
0000021786 00000 n
|
||||
0000024187 00000 n
|
||||
0000024315 00000 n
|
||||
0000026716 00000 n
|
||||
0000026844 00000 n
|
||||
0000026964 00000 n
|
||||
0000027084 00000 n
|
||||
0000027218 00000 n
|
||||
0000027292 00000 n
|
||||
0000027388 00000 n
|
||||
0000027502 00000 n
|
||||
0000027602 00000 n
|
||||
trailer
|
||||
<< /Size 37 /Root 1 0 R /Info 36 0 R >>
|
||||
startxref
|
||||
27671
|
||||
%%EOF
|
||||
|
|
@ -13,7 +13,7 @@ class Command(BaseCommand):
|
|||
parser.add_argument('--source-id', type=int, help='Refresh only this source (even when backing off)')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
qs = Source.objects.filter(synthetic=False).select_related('user').order_by('last_refreshed_at')
|
||||
qs = Source.objects.select_related('user').order_by('last_refreshed_at')
|
||||
if options['source_id']:
|
||||
qs = qs.filter(pk=options['source_id'])
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 6.1 on 2026-11-09 08:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feeds', '0006_folder_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='synthetic',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
|
@ -35,9 +35,6 @@ class Source(models.Model):
|
|||
fav = models.BooleanField(default=False)
|
||||
hidden = 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).
|
||||
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()
|
||||
Story.objects.filter(user=user).exclude(items__gone=False).delete()
|
||||
# Blogs: the oldest read articles beyond BLOG_KEEP per blog.
|
||||
for source in Source.objects.filter(user=user, kind=Source.BLOG, synthetic=False):
|
||||
for source in Source.objects.filter(user=user, kind=Source.BLOG):
|
||||
ids = list(source.items.order_by('-published').values_list('id', flat=True)[BLOG_KEEP:])
|
||||
if ids:
|
||||
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):
|
||||
"""Refresh `sources` (downloads in parallel, writes one by one).
|
||||
Returns (refreshed, failed, new items)."""
|
||||
sources = [s for s in sources if not s.synthetic] # synthetic sources have no feed to fetch
|
||||
sources = list(sources)
|
||||
now = timezone.now()
|
||||
keep = {}
|
||||
refreshed = failed = 0
|
||||
|
|
|
|||
|
|
@ -539,56 +539,3 @@ class NewsTextApiTest(TestCase):
|
|||
self.assertEqual(got['related'], [])
|
||||
single = self.client.get(f'/feeds/stories/{self.items[2].story_id}/overview/').json()
|
||||
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,
|
||||
'kind': s.kind, 'category': s.category, 'folders': s.folders, 'fulltext': s.fulltext,
|
||||
'unread': unread, 'new': new,
|
||||
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, 'synthetic': s.synthetic,
|
||||
'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify,
|
||||
# Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare.
|
||||
'interval_days': interval,
|
||||
'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count,
|
||||
|
|
@ -108,11 +108,6 @@ def _item_data(i, content=False):
|
|||
'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'progress': i.progress,
|
||||
'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:
|
||||
d['content'] = i.content
|
||||
d['content_full'] = i.content_full
|
||||
|
|
@ -361,7 +356,7 @@ def add_sources(request):
|
|||
defaults=_place(request.user, kind, x.get('category'), x.get('folders'),
|
||||
title=(x.get('title') or url)[:300]))
|
||||
added += created
|
||||
sources = Source.objects.filter(user=request.user, last_refreshed_at=None, synthetic=False)
|
||||
sources = Source.objects.filter(user=request.user, last_refreshed_at=None)
|
||||
refresh.refresh(sources, deadline=time.monotonic() + REFRESH_WAIT)
|
||||
return JsonResponse({'added': added})
|
||||
|
||||
|
|
@ -391,8 +386,6 @@ def edit_source(request, pk):
|
|||
if body.get('fulltext') in dict(Source.FULLTEXTS):
|
||||
source.fulltext = body['fulltext']
|
||||
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:
|
||||
# A section becomes a folder and back.
|
||||
if new_kind == Source.BLOG and source.category and not source.folders:
|
||||
|
|
@ -423,7 +416,7 @@ def export_opml(request):
|
|||
if (err := _auth(request)):
|
||||
return err
|
||||
groups = {}
|
||||
for s in Source.objects.filter(user=request.user, synthetic=False).order_by('kind', 'category', 'title'):
|
||||
for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'):
|
||||
kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs'
|
||||
place = s.category if s.kind == Source.NEWS else ((s.folders or [''])[0])
|
||||
label = kind + (f' – {place}' if place else '')
|
||||
|
|
@ -447,7 +440,7 @@ def export_opml(request):
|
|||
def refresh_now(request):
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user, synthetic=False),
|
||||
refreshed, failed, new = refresh.refresh(Source.objects.filter(user=request.user),
|
||||
deadline=time.monotonic() + REFRESH_WAIT)
|
||||
return JsonResponse({'refreshed': refreshed, 'failed': failed, 'new': new})
|
||||
|
||||
|
|
@ -618,7 +611,7 @@ def story_overview(request, pk):
|
|||
def blogs(request):
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source', 'episode_article')
|
||||
qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source')
|
||||
if request.GET.get('source', '').isdigit():
|
||||
qs = qs.filter(source_id=int(request.GET['source']))
|
||||
elif 'folder' in request.GET:
|
||||
|
|
@ -647,7 +640,7 @@ def blogs(request):
|
|||
def starred(request):
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
items = Item.objects.filter(user=request.user, starred=True).select_related('source', 'episode_article').order_by('-published')
|
||||
items = Item.objects.filter(user=request.user, starred=True).select_related('source').order_by('-published')
|
||||
return JsonResponse({'items': [_item_data(i) for i in items[:200]]})
|
||||
|
||||
|
||||
|
|
@ -656,13 +649,12 @@ def starred(request):
|
|||
def item(request, pk):
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source', 'episode_article').first()
|
||||
i = Item.objects.filter(user=request.user, pk=pk, gone=False).select_related('source').first()
|
||||
if i is None:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
if refresh.needs_article(i, i.source):
|
||||
refresh.load_article(i)
|
||||
d = _item_data(i, content=True)
|
||||
if not i.source.synthetic:
|
||||
d['content'] = textproc.html_without_furniture(i.content, _furniture(i.source))
|
||||
return JsonResponse({'item': d})
|
||||
|
||||
|
|
@ -752,32 +744,17 @@ def star(request, pk):
|
|||
def notifications(request):
|
||||
"""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
|
||||
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)."""
|
||||
last. Without `after` (a device's first time) nothing, only where to start."""
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
qs = Item.objects.filter(user=request.user, notify=True)
|
||||
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', '')
|
||||
if after.isdigit():
|
||||
if not after.isdigit():
|
||||
return JsonResponse({'items': [], 'last': last})
|
||||
items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True)
|
||||
.select_related('source').order_by('-published')[:50])
|
||||
out['items'] = [_item_data(i) for i in items]
|
||||
return JsonResponse(out)
|
||||
return JsonResponse({'items': [_item_data(i) for i in items], 'last': last})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
|
@ -793,8 +770,6 @@ def save_login(request):
|
|||
password = body.get('password') or ''
|
||||
if source is None or not username or not password:
|
||||
return JsonResponse({'error': 'Benutzername und Passwort nötig'}, status=400)
|
||||
if source.synthetic:
|
||||
return JsonResponse({'error': 'Diese Quelle hat keine Adresse'}, status=400)
|
||||
try:
|
||||
adapter = logins.check_and_store(request.user, source.url, username, password)
|
||||
except logins.LoginFailed as e:
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
"""The article written from an episode's transcript (docs/transcription.md): its text
|
||||
is a feeds.Item in the user's favourite blog "Podcast-Artikel"."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from feeds import fetch
|
||||
from feeds.models import Item, Source
|
||||
|
||||
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob
|
||||
|
||||
SOURCE_URL = 'diora://podcast-artikel'
|
||||
SOURCE_TITLE = 'Podcast-Artikel'
|
||||
TITLE_MAX = 300
|
||||
SUMMARY_MAX = 2000
|
||||
BODY_MAX = 1_000_000 # characters of body_html
|
||||
MODEL_MAX = 100
|
||||
KIND_MAX = 20
|
||||
|
||||
|
||||
def _auth(request):
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
return None
|
||||
|
||||
|
||||
def _body(request):
|
||||
try:
|
||||
body = json.loads(request.body or b'{}')
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
|
||||
|
||||
def article_source(user):
|
||||
"""The user's blog "Podcast-Artikel", made on first use."""
|
||||
source, _ = Source.objects.get_or_create(user=user, url=SOURCE_URL, defaults={
|
||||
'title': SOURCE_TITLE, 'kind': Source.BLOG, 'fav': True, 'synthetic': True})
|
||||
return source
|
||||
|
||||
|
||||
def _text(value, limit):
|
||||
return value.strip() if isinstance(value, str) and len(value.strip()) <= limit else None
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST', 'DELETE'])
|
||||
def episode_article(request, pk):
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).select_related('feed').first()
|
||||
if ep is None:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
if request.method == 'DELETE':
|
||||
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
|
||||
if art is not None:
|
||||
art.item.delete() # the article goes with it (cascade)
|
||||
return JsonResponse({'ok': True})
|
||||
|
||||
body = _body(request)
|
||||
if body is None:
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
title = _text(body.get('title'), TITLE_MAX)
|
||||
if not title:
|
||||
return JsonResponse({'error': 'title needed (at most %d characters)' % TITLE_MAX}, status=400)
|
||||
html_in = body.get('body_html')
|
||||
if not isinstance(html_in, str) or not html_in.strip():
|
||||
return JsonResponse({'error': 'body_html needed'}, status=400)
|
||||
if len(html_in) > BODY_MAX:
|
||||
return JsonResponse({'error': 'body_html too large'}, status=400)
|
||||
summary = body.get('summary') or ''
|
||||
if not isinstance(summary, str) or len(summary) > SUMMARY_MAX:
|
||||
return JsonResponse({'error': 'summary too long'}, status=400)
|
||||
model = body.get('model') or ''
|
||||
kind = body.get('kind') or ''
|
||||
if not isinstance(model, str) or len(model) > MODEL_MAX or not isinstance(kind, str) or len(kind) > KIND_MAX:
|
||||
return JsonResponse({'error': 'model or kind too long'}, status=400)
|
||||
|
||||
url = getattr(ep, 'link', '') or ep.audio_url
|
||||
content = fetch.clean_html(html_in, url)
|
||||
if not fetch.plain(content):
|
||||
return JsonResponse({'error': 'body_html empty'}, status=400)
|
||||
fields = {
|
||||
'url': url[:2000],
|
||||
'title': title,
|
||||
'author': (ep.feed.author or '')[:300],
|
||||
'published': timezone.now(),
|
||||
'summary': fetch.plain(summary),
|
||||
'content': content,
|
||||
'content_full': True, # nothing to fetch from a page
|
||||
'words': fetch.word_count(content),
|
||||
'image_url': (ep.artwork_url or ep.feed.artwork_url or '')[:2000],
|
||||
'read_at': None, 'progress': 0, 'progress_at': None,
|
||||
}
|
||||
with transaction.atomic():
|
||||
source = article_source(request.user)
|
||||
art = EpisodeArticle.objects.filter(episode=ep).select_related('item').first()
|
||||
if art is not None:
|
||||
item = art.item
|
||||
for k, v in fields.items():
|
||||
setattr(item, k, v)
|
||||
item.source = source
|
||||
item.save()
|
||||
art.model, art.kind = model, kind
|
||||
art.save(update_fields=['model', 'kind'])
|
||||
else:
|
||||
guid = hashlib.sha256(f'diora-article:{ep.pk}'.encode()).hexdigest()
|
||||
item = Item.objects.create(source=source, user=request.user, kind=Source.BLOG,
|
||||
guid_hash=guid, **fields)
|
||||
EpisodeArticle.objects.create(episode=ep, item=item, model=model, kind=kind)
|
||||
# Saving fulfils the wish and frees the reservation.
|
||||
TranscriptionJob.objects.filter(episode=ep).update(want_article=False, claimed_by='', claimed_until=None)
|
||||
return JsonResponse({'item_id': item.id})
|
||||
|
|
@ -128,28 +128,25 @@ FetchUnavailable = ChaptersUnavailable
|
|||
FetchInvalid = ChaptersInvalid
|
||||
|
||||
|
||||
def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
|
||||
"""`method` (GET or POST) `url` with every redirect hop checked; the streamed response of
|
||||
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.
|
||||
def _open(url, headers):
|
||||
"""GET `url` with every redirect hop checked; the streamed response of
|
||||
the final hop, which the caller has to close.
|
||||
|
||||
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
|
||||
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):
|
||||
try:
|
||||
assert_safe_url(url)
|
||||
except WebDAVInputError as e:
|
||||
raise ChaptersInvalid(str(e))
|
||||
try:
|
||||
send = requests.get if method == 'GET' else requests.post
|
||||
r = send(url, timeout=timeout, allow_redirects=False, stream=True, headers=headers, **kwargs)
|
||||
r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
|
||||
except requests.RequestException as e:
|
||||
raise ChaptersUnavailable(e.__class__.__name__)
|
||||
try:
|
||||
_assert_peer_is_safe(r)
|
||||
if r.status_code in redirects:
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
location = r.headers.get('Location')
|
||||
if not location:
|
||||
raise ChaptersInvalid('redirect without a location')
|
||||
|
|
@ -158,7 +155,7 @@ def _open(url, headers, method='GET', timeout=CHAPTERS_TIMEOUT, **kwargs):
|
|||
continue
|
||||
if r.status_code >= 500 or r.status_code == 429:
|
||||
raise ChaptersUnavailable(f'HTTP {r.status_code}')
|
||||
if r.status_code >= 400 or r.status_code in (301, 302, 303):
|
||||
if r.status_code >= 400:
|
||||
raise ChaptersInvalid(f'HTTP {r.status_code}')
|
||||
except WebDAVInputError as e:
|
||||
r.close()
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 6.1 on 2026-11-09 08:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0026_podcastepisode_sponsor'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='podcastepisode',
|
||||
name='link',
|
||||
field=models.URLField(blank=True, max_length=1000),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
# Generated by Django 6.1 on 2026-11-09 08:20
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('feeds', '0007_transcription'),
|
||||
('podcasts', '0027_podcastepisode_link'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EpisodeArticle',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('model', models.CharField(blank=True, max_length=100)),
|
||||
('kind', models.CharField(blank=True, max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='article', to='podcasts.podcastepisode')),
|
||||
('item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='episode_article', to='feeds.item')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TranscriptionJob',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('state', models.CharField(choices=[('none', 'none'), ('queued', 'queued'), ('downloading', 'downloading'), ('transcribing', 'transcribing'), ('done', 'done'), ('error', 'error')], default='none', max_length=15)),
|
||||
('chunks_done', models.PositiveIntegerField(default=0)),
|
||||
('chunks_total', models.PositiveIntegerField(default=0)),
|
||||
('error', models.CharField(blank=True, max_length=500)),
|
||||
('want_article', models.BooleanField(default=False)),
|
||||
('claimed_by', models.CharField(blank=True, max_length=60)),
|
||||
('claimed_until', models.DateTimeField(blank=True, null=True)),
|
||||
('data', models.JSONField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||
('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_job', to='podcasts.podcastepisode')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TranscriptionSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('engine', models.CharField(choices=[('builtin', 'Eingebaut'), ('custom', 'Eigene Instanz')], default='builtin', max_length=10)),
|
||||
('url', models.CharField(blank=True, max_length=500)),
|
||||
('token_encrypted', models.TextField(blank=True)),
|
||||
('model', models.CharField(blank=True, max_length=100)),
|
||||
('language', models.CharField(blank=True, max_length=10)),
|
||||
('parallel', models.PositiveSmallIntegerField(default=3)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcription_settings', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -164,8 +164,6 @@ class PodcastEpisode(models.Model):
|
|||
title = models.CharField(max_length=500)
|
||||
description = models.TextField(blank=True)
|
||||
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)
|
||||
pub_date = models.DateTimeField(null=True, blank=True)
|
||||
episode_number = models.IntegerField(null=True, blank=True)
|
||||
|
|
@ -219,68 +217,6 @@ class EpisodeTranscript(models.Model):
|
|||
return f'Transcript of {self.episode}'
|
||||
|
||||
|
||||
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),
|
||||
# the same rule as the apps' "als gehört zählen".
|
||||
PLAYED_BEFORE_END_SECONDS = 30
|
||||
|
|
|
|||
|
|
@ -37,9 +37,6 @@ ALLOWED_ATTRS = {
|
|||
URL_ATTRS = {'href', 'src'}
|
||||
LINK_SCHEMES = {'http', 'https', 'mailto'}
|
||||
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]+')
|
||||
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
|
||||
|
|
@ -84,9 +81,7 @@ class _Sanitizer(HTMLParser):
|
|||
name = name.lower()
|
||||
if name not in allowed or value is None:
|
||||
continue
|
||||
if tag == 'a' and name == 'href' and TIME_LINK.match(value.strip()):
|
||||
value = value.strip()
|
||||
elif name in URL_ATTRS:
|
||||
if name in URL_ATTRS:
|
||||
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
|
||||
if value is None:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,146 +0,0 @@
|
|||
import json
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from feeds.models import Item, Source
|
||||
|
||||
from .models import EpisodeArticle, PodcastEpisode, PodcastFeed, TranscriptionJob
|
||||
|
||||
BODY = ('<p>Zusammenfassung <script>alert(1)</script></p><h2>Teil eins</h2>'
|
||||
'<p><a href="#t=754" onclick="evil()">▶ ab 12:34</a> und <a href="https://example.org/x">Quelle</a></p>')
|
||||
|
||||
|
||||
class ArticleTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://p.example/rss', title='Salon',
|
||||
author='Anna Autorin', artwork_url='https://p.example/feed.jpg')
|
||||
self.ep = PodcastEpisode.objects.create(feed=self.feed, guid='e1', title='Folge 1',
|
||||
audio_url='https://p.example/e1.mp3', artwork_url='https://p.example/e1.jpg')
|
||||
|
||||
def url(self, ep=None):
|
||||
return f'/podcasts/episodes/{(ep or self.ep).id}/article/'
|
||||
|
||||
def post(self, data=None, ep=None):
|
||||
data = {'title': 'Ein Artikel', 'summary': 'Kurz.', 'body_html': BODY, 'model': 'claude', 'kind': 'Interview'} \
|
||||
if data is None else data
|
||||
return self.client.post(self.url(ep), json.dumps(data), content_type='application/json')
|
||||
|
||||
def test_requires_login_and_ownership(self):
|
||||
self.client.logout()
|
||||
self.assertEqual(self.post().status_code, 401)
|
||||
self.assertEqual(self.client.delete(self.url()).status_code, 401)
|
||||
bob = User.objects.create_user(username='bob', password='pw12345678')
|
||||
self.client.force_login(bob)
|
||||
self.assertEqual(self.post().status_code, 404)
|
||||
self.assertEqual(self.client.delete(self.url()).status_code, 404)
|
||||
self.assertFalse(Item.objects.exists())
|
||||
|
||||
def test_create(self):
|
||||
r = self.post()
|
||||
self.assertEqual(r.status_code, 200)
|
||||
item = Item.objects.get(pk=r.json()['item_id'])
|
||||
self.assertEqual(item.user, self.user)
|
||||
self.assertEqual(item.kind, Source.BLOG)
|
||||
self.assertEqual(item.title, 'Ein Artikel')
|
||||
self.assertEqual(item.summary, 'Kurz.')
|
||||
self.assertEqual(item.author, 'Anna Autorin')
|
||||
self.assertEqual(item.image_url, 'https://p.example/e1.jpg')
|
||||
self.assertEqual(item.url, 'https://p.example/e1.mp3')
|
||||
self.assertTrue(item.content_full)
|
||||
self.assertGreater(item.words, 3)
|
||||
self.assertLess((timezone.now() - item.published).total_seconds(), 60)
|
||||
art = EpisodeArticle.objects.get(episode=self.ep)
|
||||
self.assertEqual((art.item, art.model, art.kind), (item, 'claude', 'Interview'))
|
||||
self.assertEqual(item.episode_article, art)
|
||||
|
||||
def test_image_falls_back_to_feed_artwork(self):
|
||||
self.ep.artwork_url = ''
|
||||
self.ep.save()
|
||||
item = Item.objects.get(pk=self.post().json()['item_id'])
|
||||
self.assertEqual(item.image_url, 'https://p.example/feed.jpg')
|
||||
|
||||
def test_sanitizer_keeps_time_links_only(self):
|
||||
item = Item.objects.get(pk=self.post().json()['item_id'])
|
||||
self.assertNotIn('<script', item.content)
|
||||
self.assertNotIn('alert(1)', item.content)
|
||||
self.assertNotIn('onclick', item.content)
|
||||
self.assertIn('href="#t=754"', item.content)
|
||||
self.assertIn('href="https://example.org/x"', item.content)
|
||||
evil = self.post({'title': 'x', 'body_html': '<a href="#t=1" 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['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(set(d['episode']), {'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||
self.assertEqual(set(d['episode']), {'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||
'feed__id', 'feed__title', 'feed__artwork_url', 'position_seconds',
|
||||
'in_queue'})
|
||||
self.assertEqual((d['episode']['id'], d['episode']['in_queue']), (self.ep.id, True))
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import feedparser
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
|
||||
from .models import PodcastEpisode, PodcastFeed
|
||||
from .views import FeedFetch, _apply_feed, _entry_link
|
||||
|
||||
|
||||
def _feed(link):
|
||||
item_link = f'<link>{link}</link>' if link is not None else ''
|
||||
return feedparser.parse(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>99 ZU EINS</title>'
|
||||
'<item><title>Folge 724</title><guid>g724</guid>' + item_link +
|
||||
'<enclosure url="https://cdn.example/724.mp3" type="audio/mpeg" length="1"/></item>'
|
||||
'</channel></rss>'
|
||||
)
|
||||
|
||||
|
||||
class EpisodeLinkTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/rss', title='99 ZU EINS')
|
||||
|
||||
def fetch(self, link):
|
||||
_apply_feed(self.feed, FeedFetch(200, _feed(link)))
|
||||
return PodcastEpisode.objects.get(feed=self.feed)
|
||||
|
||||
def test_entry_link_takes_only_web_addresses(self):
|
||||
self.assertEqual(_entry_link({'link': ' https://x.fireside.fm/724 '}), 'https://x.fireside.fm/724')
|
||||
self.assertEqual(_entry_link({'link': 'javascript:alert(1)'}), '')
|
||||
self.assertEqual(_entry_link({}), '')
|
||||
|
||||
def test_a_new_episode_gets_the_items_link(self):
|
||||
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
|
||||
|
||||
def test_a_refresh_fills_the_link_of_an_episode_that_has_none(self):
|
||||
self.assertEqual(self.fetch(None).link, '')
|
||||
self.assertEqual(self.fetch('https://99zueins.fireside.fm/724').link, 'https://99zueins.fireside.fm/724')
|
||||
|
||||
def test_episode_detail_returns_the_link(self):
|
||||
ep = self.fetch('https://99zueins.fireside.fm/724')
|
||||
r = self.client.get(f'/podcasts/episodes/{ep.id}/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['link'], 'https://99zueins.fireside.fm/724')
|
||||
|
|
@ -1,821 +0,0 @@
|
|||
"""The transcription pipeline (podcasts/transcribe.py) against a fake Whisper instance, the
|
||||
endpoints around it and the notice in /feeds/notifications/."""
|
||||
import email
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
from datetime import timedelta
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import OperationalError
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from feeds.models import Item, Source
|
||||
|
||||
from . import transcribe, transcription, youtube
|
||||
from .models import (
|
||||
EpisodeArticle, EpisodeTranscript, PodcastEpisode, PodcastFeed, TranscriptionJob, TranscriptionSettings,
|
||||
)
|
||||
|
||||
RATE = 16000
|
||||
TOTAL = 120
|
||||
# Where the long pauses are (start, length): the pieces should be cut in these. The short
|
||||
# ones (0.3 s every 4 s) are only there to tempt.
|
||||
LONG_PAUSES = [(36.5, 1.6), (83.0, 1.4)]
|
||||
SEGMENTS = [{'start': 0.0, 'end': 2.0, 'text': ' Hallo '}, {'start': 2.0, 'end': 5.5, 'text': 'Welt.'}]
|
||||
|
||||
|
||||
def make_audio():
|
||||
"""~2 minutes of tones with pauses, as mp3 bytes (and the wav as a check of the length)."""
|
||||
samples = []
|
||||
t = 0.0
|
||||
pauses = [(s, s + n) for s, n in LONG_PAUSES]
|
||||
for i in range(RATE * TOTAL):
|
||||
t = i / RATE
|
||||
silent = any(a <= t < b for a, b in pauses) or (t % 4.0) > 3.7
|
||||
value = 0 if silent else int(9000 * math.sin(2 * math.pi * (180 + 40 * ((i // 8000) % 5)) * t))
|
||||
samples.append(value)
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, 'wb') as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(RATE)
|
||||
w.writeframes(struct.pack('<%dh' % len(samples), *samples))
|
||||
import imageio_ffmpeg
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src, dst = os.path.join(tmp, 'a.wav'), os.path.join(tmp, 'a.mp3')
|
||||
open(src, 'wb').write(buf.getvalue())
|
||||
subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', src, '-b:a', '64k', dst], check=True)
|
||||
return open(dst, 'rb').read()
|
||||
|
||||
|
||||
_AUDIO = []
|
||||
|
||||
|
||||
def audio():
|
||||
if not _AUDIO:
|
||||
_AUDIO.append(make_audio())
|
||||
return _AUDIO[0]
|
||||
|
||||
|
||||
def decoded_seconds(data, suffix):
|
||||
"""How long the uploaded piece is, by decoding it."""
|
||||
import imageio_ffmpeg
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, 'p' + suffix)
|
||||
open(path, 'wb').write(data)
|
||||
out = subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), '-loglevel', 'error', '-i', path, '-f', 's16le',
|
||||
'-ac', '1', '-ar', str(RATE), '-'], stdout=subprocess.PIPE, check=True).stdout
|
||||
return len(out) / 2 / RATE
|
||||
|
||||
|
||||
class Fake:
|
||||
"""A local HTTP server: GET /ep.mp3 is the test audio, POST /v1/audio/transcriptions a Whisper."""
|
||||
|
||||
def __init__(self, fail_first=0, fail_status=500, delay=0, on_call=None):
|
||||
self.fail_first, self.fail_status, self.delay, self.on_call = fail_first, fail_status, delay, on_call
|
||||
self.calls = [] # {'fields', 'filename', 'body', 'auth'}
|
||||
self.lock = threading.Lock()
|
||||
fake = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/missing.mp3':
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
body = audio_holder.data or audio()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'audio/mpeg')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
raw = self.rfile.read(int(self.headers['Content-Length']))
|
||||
msg = email.message_from_bytes(
|
||||
b'Content-Type: ' + self.headers['Content-Type'].encode() + b'\r\n\r\n' + raw)
|
||||
fields, filename, body = {}, '', b''
|
||||
for part in msg.get_payload():
|
||||
name = part.get_param('name', header='content-disposition')
|
||||
if part.get_filename():
|
||||
filename, body = part.get_filename(), part.get_payload(decode=True)
|
||||
else:
|
||||
fields[name] = part.get_payload(decode=True).decode()
|
||||
with fake.lock:
|
||||
fake.calls.append({'fields': fields, 'filename': filename, 'body': body,
|
||||
'auth': self.headers.get('Authorization')})
|
||||
n = len(fake.calls)
|
||||
if fake.on_call:
|
||||
fake.on_call(n)
|
||||
if fake.delay:
|
||||
time.sleep(fake.delay)
|
||||
if n <= fake.fail_first or fake.fail_first < 0:
|
||||
self.send_response(fake.fail_status)
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
return
|
||||
out = json.dumps({'language': 'german', 'segments': SEGMENTS}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(out)))
|
||||
self.end_headers()
|
||||
self.wfile.write(out)
|
||||
|
||||
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
self.server.daemon_threads = True
|
||||
self.port = self.server.server_address[1]
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
|
||||
@property
|
||||
def whisper(self):
|
||||
return f'http://127.0.0.1:{self.port}/v1'
|
||||
|
||||
def url(self, path='/ep.mp3'):
|
||||
return f'http://127.0.0.1:{self.port}{path}'
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
def leftovers():
|
||||
base = tempfile.gettempdir()
|
||||
return {d for d in os.listdir(base) if d.startswith('diora-transcribe-')}
|
||||
|
||||
|
||||
FAST = dict(CHUNK_SECONDS=40, CUT_WINDOW=8, MIN_TAIL=10, RETRY_PAUSES=(0, 0, 0))
|
||||
|
||||
|
||||
def wait_for(cond, timeout=30):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if cond():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
class PipelineBase(TransactionTestCase):
|
||||
def setUp(self):
|
||||
for k, v in FAST.items():
|
||||
patcher = patch.object(transcribe, k, v)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='Der Podcast',
|
||||
author='Anna Autorin')
|
||||
self.fake = Fake()
|
||||
self.addCleanup(self.fake.stop)
|
||||
self.before = leftovers()
|
||||
|
||||
def job(self, audio_path='/ep.mp3', **kw):
|
||||
n = PodcastEpisode.objects.count()
|
||||
ep = PodcastEpisode.objects.create(feed=self.feed, guid=f'g{n}', title=f'Folge {n}',
|
||||
audio_url=self.fake.url(audio_path), duration_seconds=TOTAL)
|
||||
return TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, **kw)
|
||||
|
||||
def reload(self, job):
|
||||
for _ in range(50): # the in-memory test database may be locked for a moment by the worker
|
||||
try:
|
||||
return TranscriptionJob.objects.get(pk=job.pk)
|
||||
except OperationalError:
|
||||
time.sleep(0.05)
|
||||
return TranscriptionJob.objects.get(pk=job.pk)
|
||||
|
||||
def assertClean(self):
|
||||
self.assertEqual(leftovers(), self.before)
|
||||
|
||||
|
||||
class PlanTests(PipelineBase):
|
||||
def test_cuts_land_in_the_long_pauses(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src, pcm = os.path.join(tmp, 'a.mp3'), os.path.join(tmp, 'a.pcm')
|
||||
open(src, 'wb').write(audio())
|
||||
transcribe._decode(src, pcm)
|
||||
total = os.path.getsize(pcm) / 2 / RATE
|
||||
pieces = transcribe.plan(total, transcribe.pauses_of(pcm))
|
||||
self.assertAlmostEqual(total, TOTAL, delta=0.5)
|
||||
self.assertEqual(len(pieces), 3)
|
||||
cuts = [p[1] for p in pieces[:-1]]
|
||||
for cut, (start, length) in zip(cuts, LONG_PAUSES):
|
||||
self.assertTrue(start <= cut <= start + length, f'{cut} not in the pause {start}+{length}')
|
||||
self.assertEqual(pieces[0][0], 0)
|
||||
self.assertEqual(pieces[-1][1], total)
|
||||
self.assertEqual([a[1] for a in pieces[:-1]], [b[0] for b in pieces[1:]])
|
||||
|
||||
def test_plan_without_pauses_cuts_at_the_target(self):
|
||||
self.assertEqual(transcribe.plan(100, []), [(0.0, 40), (40, 80), (80, 100)])
|
||||
self.assertEqual(transcribe.plan(30, []), [(0.0, 30)])
|
||||
# a short tail is joined to the last piece
|
||||
self.assertEqual(transcribe.plan(85, []), [(0.0, 40), (40, 85)])
|
||||
|
||||
|
||||
@override_settings(WHISPER_URL='')
|
||||
class RunTests(PipelineBase):
|
||||
def configure(self, **kw):
|
||||
conf = TranscriptionSettings(user=self.user, **kw)
|
||||
conf.save()
|
||||
return conf
|
||||
|
||||
def test_builtin_run_end_to_end(self):
|
||||
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||
self.configure(language='de', parallel=2)
|
||||
job = self.job()
|
||||
started = time.time()
|
||||
transcribe.run(job.pk)
|
||||
took = time.time() - started
|
||||
print(f'\n[pipeline run of the {TOTAL} s test audio took {took:.1f} s]')
|
||||
job = self.reload(job)
|
||||
self.assertEqual((job.state, job.error), ('done', ''))
|
||||
self.assertEqual((job.chunks_done, job.chunks_total), (3, 3))
|
||||
self.assertIsNotNone(job.finished_at)
|
||||
self.assertEqual(len(self.fake.calls), 3)
|
||||
for c in self.fake.calls:
|
||||
self.assertEqual(c['fields']['model'], 'whisper-1')
|
||||
self.assertEqual(c['fields']['language'], 'de')
|
||||
self.assertEqual(c['fields']['response_format'], 'verbose_json')
|
||||
self.assertEqual(c['fields']['prompt'], 'Der Podcast. Folge 0. Anna Autorin')
|
||||
self.assertTrue(c['filename'].endswith('.flac'))
|
||||
self.assertIsNone(c['auth']) # the token only goes to custom instances
|
||||
# the uploaded pieces are the planned ones: the same total, cut at the pauses
|
||||
lengths = sorted(decoded_seconds(c['body'], '.flac') for c in self.fake.calls)
|
||||
self.assertAlmostEqual(sum(lengths), TOTAL, delta=0.5)
|
||||
data = job.data
|
||||
self.assertEqual(data['source'], {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'})
|
||||
segs = data['segments']
|
||||
starts = [s['start'] for s in segs]
|
||||
self.assertEqual(starts, sorted(starts))
|
||||
self.assertTrue(all(s['speaker'] == '' for s in segs))
|
||||
# every piece answered "Hallo Welt." at 0 to 5.5 s: its start is where the piece starts,
|
||||
# which lies on a cut in a long pause, and the first at zero
|
||||
self.assertEqual(starts[0], 0)
|
||||
cuts = starts[1:]
|
||||
self.assertEqual(len(cuts), 2)
|
||||
for cut, (p, n) in zip(cuts, LONG_PAUSES):
|
||||
self.assertTrue(p <= cut <= p + n, f'{cut} not in {p}+{n}')
|
||||
self.assertEqual(segs[0]['text'], 'Hallo Welt.')
|
||||
self.assertAlmostEqual(segs[0]['end'], 5.5)
|
||||
self.assertClean()
|
||||
|
||||
def test_language_empty_takes_what_whisper_detects_and_custom_gets_opus_and_token(self):
|
||||
conf = self.configure(engine='custom', url=self.fake.whisper, model='large-v3', language='')
|
||||
conf.token_encrypted = transcription.logins.encrypt('tok')
|
||||
conf.save()
|
||||
job = self.job()
|
||||
with patch.object(transcribe.chapters, 'assert_safe_url'), \
|
||||
patch.object(transcribe.chapters, '_assert_peer_is_safe'):
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual((job.state, job.error), ('done', ''))
|
||||
self.assertEqual(job.data['source'], {'type': 'whisper', 'model': 'large-v3', 'language': 'german'})
|
||||
for c in self.fake.calls:
|
||||
self.assertTrue(c['filename'].endswith('.ogg'))
|
||||
self.assertNotIn('language', c['fields'])
|
||||
self.assertEqual(c['auth'], 'Bearer tok')
|
||||
self.assertLess(len(c['body']), decoded_seconds(c['body'], '.ogg') * 6500) # 32 kbit/s: about 4 kB a second, ogg and VBR on top
|
||||
self.assertAlmostEqual(sum(decoded_seconds(c['body'], '.ogg') for c in self.fake.calls), TOTAL, delta=0.5)
|
||||
self.assertClean()
|
||||
|
||||
def test_custom_instance_on_a_private_address_is_refused(self):
|
||||
self.configure(engine='custom', url=self.fake.whisper)
|
||||
job = self.job()
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual(job.state, 'error')
|
||||
self.assertIn('Whisper', job.error)
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
def test_unreachable_builtin_waits_and_tries_again(self):
|
||||
import socket
|
||||
sock = socket.socket()
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close() # nothing listens there now
|
||||
with override_settings(WHISPER_URL=f'http://127.0.0.1:{port}/v1'):
|
||||
self.configure()
|
||||
job = self.job()
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual(job.state, 'queued')
|
||||
self.assertIn('nicht erreichbar', job.error)
|
||||
self.assertGreater(transcribe._not_before[job.pk], time.time())
|
||||
self.assertEqual(self.fake.calls, []) # nothing downloaded while the instance is away
|
||||
self.assertEqual(transcribe._next_job(), transcribe.WAIT)
|
||||
transcribe._not_before[job.pk] = 0 # due again: the instance is back
|
||||
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual((job.state, job.error), ('done', ''))
|
||||
transcribe._not_before.clear()
|
||||
|
||||
def test_waiting_gives_up_after_two_days(self):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
with override_settings(WHISPER_URL='http://127.0.0.1:9/v1'):
|
||||
self.configure()
|
||||
job = self.job()
|
||||
TranscriptionJob.objects.filter(pk=job.pk).update(created_at=timezone.now() - timedelta(days=3))
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual(job.state, 'error')
|
||||
self.assertIn('zwei Tage', job.error)
|
||||
|
||||
@override_settings(WHISPER_URL='')
|
||||
def test_retries_then_succeeds(self):
|
||||
self.fake.fail_first = 2
|
||||
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||
self.configure(parallel=1)
|
||||
job = self.job()
|
||||
transcribe.run(job.pk)
|
||||
self.assertEqual(self.reload(job).state, 'done')
|
||||
self.assertEqual(len(self.fake.calls), 3 + 2)
|
||||
|
||||
def test_gives_up_after_three_retries(self):
|
||||
self.fake.fail_first = -1
|
||||
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||
self.configure(parallel=1)
|
||||
job = self.job()
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual(job.state, 'error')
|
||||
self.assertEqual(job.error, 'Whisper: HTTP 500')
|
||||
self.assertEqual(len(self.fake.calls), 4) # the first try and three more
|
||||
self.assertIsNone(job.data)
|
||||
self.assertClean()
|
||||
|
||||
def test_a_refusal_is_not_retried(self):
|
||||
self.fake.fail_first, self.fake.fail_status = -1, 401
|
||||
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||
self.configure(parallel=1)
|
||||
job = self.job()
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual((job.state, job.error), ('error', 'Whisper: HTTP 401'))
|
||||
self.assertEqual(len(self.fake.calls), 1)
|
||||
|
||||
def test_download_failure(self):
|
||||
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||
job = self.job('/missing.mp3')
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual((job.state, job.error), ('error', 'Download: HTTP 404'))
|
||||
self.assertClean()
|
||||
|
||||
def test_not_audio(self):
|
||||
with override_settings(WHISPER_URL=self.fake.whisper), patch.object(audio_holder, 'data', b'<html>no</html>'):
|
||||
job = self.job()
|
||||
transcribe.run(job.pk)
|
||||
job = self.reload(job)
|
||||
self.assertEqual(job.state, 'error')
|
||||
self.assertTrue(job.error.startswith('ffmpeg:'), job.error)
|
||||
self.assertClean()
|
||||
|
||||
def test_no_builtin_instance(self):
|
||||
job = self.job()
|
||||
transcribe.run(job.pk)
|
||||
self.assertEqual(self.reload(job).error, 'Keine eingebaute Instanz eingerichtet')
|
||||
|
||||
def test_cancel_while_running(self):
|
||||
job = self.job()
|
||||
|
||||
def cancel(n):
|
||||
if n == 1:
|
||||
TranscriptionJob.objects.filter(pk=job.pk).delete()
|
||||
from django.db import connection
|
||||
connection.close()
|
||||
self.fake.on_call, self.fake.delay = cancel, 0.2
|
||||
with override_settings(WHISPER_URL=self.fake.whisper):
|
||||
self.configure(parallel=1)
|
||||
transcribe.run(job.pk)
|
||||
self.assertFalse(TranscriptionJob.objects.filter(pk=job.pk).exists())
|
||||
self.assertLessEqual(len(self.fake.calls), 1) # no further piece was sent
|
||||
self.assertClean()
|
||||
|
||||
def test_cancelled_while_waiting_is_not_started(self):
|
||||
job = self.job()
|
||||
job.delete()
|
||||
transcribe.run(job.pk)
|
||||
self.assertEqual(self.fake.calls, [])
|
||||
|
||||
|
||||
class _Holder:
|
||||
"""Lets a test swap the audio the fake server sends."""
|
||||
data = None
|
||||
|
||||
|
||||
audio_holder = _Holder()
|
||||
|
||||
|
||||
class QueueTests(PipelineBase):
|
||||
"""The runner and its queue. The work itself (_transcribe) is replaced by a stand-in the test
|
||||
holds back, so only the runner thread writes to the database while the test looks at it."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.gate = threading.Event()
|
||||
self.started = []
|
||||
patcher = patch.object(transcribe, '_resumed', True)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
work = patch.object(transcribe, '_transcribe', side_effect=self.work)
|
||||
work.start()
|
||||
self.addCleanup(work.stop)
|
||||
self.addCleanup(self.gate.set)
|
||||
self.addCleanup(lambda: wait_for(lambda: transcribe._runner is None))
|
||||
|
||||
def work(self, job_id):
|
||||
self.started.append(job_id)
|
||||
self.gate.wait(30)
|
||||
return {'segments': [{'start': 0, 'end': 1, 'speaker': '', 'text': 'x'}], 'source': {'type': 'whisper'}}
|
||||
|
||||
def test_one_job_at_a_time_oldest_first(self):
|
||||
first, second, third = self.job(), self.job(), self.job()
|
||||
transcribe.kick()
|
||||
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
|
||||
transcribe.kick() # a second runner doesn't start
|
||||
time.sleep(0.3)
|
||||
self.assertEqual(self.started, [first.pk])
|
||||
self.assertEqual([self.reload(j).state for j in (first, second, third)], ['downloading', 'queued', 'queued'])
|
||||
self.gate.set()
|
||||
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||
self.assertEqual(self.started, [first.pk, second.pk, third.pk])
|
||||
jobs = [self.reload(j) for j in (first, second, third)]
|
||||
self.assertEqual([j.state for j in jobs], ['done'] * 3)
|
||||
self.assertEqual(sorted(j.finished_at for j in jobs), [j.finished_at for j in jobs])
|
||||
|
||||
def test_a_job_queued_while_the_runner_works_is_taken_too(self):
|
||||
first = self.job()
|
||||
transcribe.kick()
|
||||
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
|
||||
second = self.job()
|
||||
transcribe.kick()
|
||||
self.gate.set()
|
||||
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||
self.assertEqual(self.started, [first.pk, second.pk])
|
||||
|
||||
def test_a_cancelled_waiting_job_is_skipped(self):
|
||||
first, second, third = self.job(), self.job(), self.job()
|
||||
transcribe.kick()
|
||||
self.assertTrue(wait_for(lambda: self.started == [first.pk]))
|
||||
second.delete()
|
||||
self.gate.set()
|
||||
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||
self.assertEqual(self.started, [first.pk, third.pk])
|
||||
|
||||
def test_a_crashing_job_does_not_stop_the_queue(self):
|
||||
first, second = self.job(), self.job()
|
||||
self.gate.set()
|
||||
calls = []
|
||||
|
||||
def work(job_id):
|
||||
calls.append(job_id)
|
||||
if job_id == first.pk:
|
||||
raise RuntimeError('boom')
|
||||
return self.work(job_id)
|
||||
with patch.object(transcribe, '_transcribe', side_effect=work):
|
||||
transcribe.kick()
|
||||
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||
self.assertEqual(calls, [first.pk, second.pk])
|
||||
self.assertEqual((self.reload(first).state, self.reload(first).error), ('error', 'RuntimeError: boom'))
|
||||
self.assertEqual(self.reload(second).state, 'done')
|
||||
|
||||
def test_stuck_jobs_start_again_after_a_restart(self):
|
||||
self.gate.set()
|
||||
job = self.job()
|
||||
TranscriptionJob.objects.filter(pk=job.pk).update(state='transcribing', chunks_done=2, chunks_total=3)
|
||||
with patch.object(transcribe, '_resumed', False):
|
||||
transcribe.kick()
|
||||
self.assertTrue(wait_for(lambda: transcribe._runner is None))
|
||||
self.assertEqual(self.started, [job.pk])
|
||||
self.assertEqual(self.reload(job).state, 'done')
|
||||
|
||||
def test_nothing_queued_starts_no_runner(self):
|
||||
transcribe.kick()
|
||||
self.assertIsNone(transcribe._runner)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- endpoints
|
||||
|
||||
class EndpointBase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='Der Podcast')
|
||||
self.ep = self.episode('e1')
|
||||
kick = patch.object(transcribe, 'kick')
|
||||
self.kick = kick.start()
|
||||
self.addCleanup(kick.stop)
|
||||
|
||||
def episode(self, guid, **kw):
|
||||
kw.setdefault('audio_url', f'https://example.org/{guid}.mp3')
|
||||
return PodcastEpisode.objects.create(feed=self.feed, guid=guid, title=f'Folge {guid}', duration_seconds=600, **kw)
|
||||
|
||||
def post(self, url, body=None, **extra):
|
||||
return self.client.post(url, json.dumps(body or {}), content_type='application/json', **extra)
|
||||
|
||||
def done_job(self, ep, **kw):
|
||||
kw.setdefault('data', {'segments': [{'start': 0, 'end': 4, 'speaker': '', 'text': 'Hallo.'}],
|
||||
'source': {'type': 'whisper', 'model': 'whisper-1', 'language': 'de'}})
|
||||
return TranscriptionJob.objects.create(episode=ep, state='done', finished_at=timezone.now(), **kw)
|
||||
|
||||
|
||||
class TranscribeEndpointTests(EndpointBase):
|
||||
def url(self, ep=None):
|
||||
return f'/podcasts/episodes/{(ep or self.ep).id}/transcribe/'
|
||||
|
||||
def test_requires_login_and_ownership(self):
|
||||
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||
self.client.force_login(other)
|
||||
self.assertEqual(self.client.get(self.url()).status_code, 404)
|
||||
self.assertEqual(self.post(self.url()).status_code, 404)
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get(self.url()).status_code, 401)
|
||||
|
||||
def test_none_before_a_job(self):
|
||||
self.assertEqual(self.client.get(self.url()).json(), {
|
||||
'state': 'none', 'chunks_done': 0, 'chunks_total': 0, 'error': '', 'want_article': False,
|
||||
'started_at': None, 'finished_at': None})
|
||||
|
||||
@patch('podcasts.transcription._too_large', return_value=False)
|
||||
def test_post_queues_a_job_and_starts_the_runner(self, _):
|
||||
r = self.post(self.url())
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual((r.json()['state'], r.json()['want_article']), ('queued', False))
|
||||
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).state, 'queued')
|
||||
self.kick.assert_called()
|
||||
|
||||
@patch('podcasts.transcription._too_large', return_value=False)
|
||||
def test_post_again_only_raises_the_article_wish(self, _):
|
||||
self.post(self.url())
|
||||
first = TranscriptionJob.objects.get(episode=self.ep).pk
|
||||
r = self.post(self.url(), {'article': True})
|
||||
self.assertTrue(r.json()['want_article'])
|
||||
r = self.post(self.url(), {'article': False}) # never lowers it
|
||||
self.assertTrue(r.json()['want_article'])
|
||||
self.assertEqual(TranscriptionJob.objects.filter(episode=self.ep).count(), 1)
|
||||
self.assertEqual(TranscriptionJob.objects.get(episode=self.ep).pk, first)
|
||||
|
||||
@patch('podcasts.transcription._too_large', return_value=False)
|
||||
def test_post_with_article_true_sets_the_wish(self, _):
|
||||
self.assertTrue(self.post(self.url(), {'article': True}).json()['want_article'])
|
||||
|
||||
def test_youtube_is_refused(self):
|
||||
ep = self.episode('yt', audio_url=youtube.WATCH + 'abc123')
|
||||
r = self.post(self.url(ep))
|
||||
self.assertEqual((r.status_code, r.json()), (400, {'error': 'youtube'}))
|
||||
self.assertFalse(TranscriptionJob.objects.exists())
|
||||
|
||||
@patch('podcasts.transcription._too_large', return_value=True)
|
||||
def test_too_large_is_refused(self, _):
|
||||
r = self.post(self.url())
|
||||
self.assertEqual((r.status_code, r.json()), (400, {'error': 'too large'}))
|
||||
self.assertFalse(TranscriptionJob.objects.exists())
|
||||
|
||||
def test_the_size_check_reads_the_content_length(self):
|
||||
from . import silence
|
||||
resp = type('R', (), {'headers': {'Content-Length': str(silence.MAX_BYTES + 1)}})()
|
||||
with patch('podcasts.transcription.requests.head', return_value=resp):
|
||||
self.assertTrue(transcription._too_large('https://example.org/x.mp3'))
|
||||
resp.headers = {'Content-Length': '1000'}
|
||||
with patch('podcasts.transcription.requests.head', return_value=resp):
|
||||
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
|
||||
with patch('podcasts.transcription.requests.head', side_effect=transcription.requests.ConnectionError):
|
||||
self.assertFalse(transcription._too_large('https://example.org/x.mp3'))
|
||||
|
||||
def test_a_whisper_transcript_is_done_already(self):
|
||||
self.done_job(self.ep)
|
||||
r = self.post(self.url(), {'article': True})
|
||||
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
|
||||
self.kick.assert_not_called()
|
||||
self.assertTrue(TranscriptionJob.objects.get(episode=self.ep).want_article)
|
||||
|
||||
def test_a_feed_transcript_is_done_already(self):
|
||||
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
|
||||
self.ep.save()
|
||||
r = self.post(self.url(), {'article': True})
|
||||
self.assertEqual((r.json()['state'], r.json()['want_article']), ('done', True))
|
||||
job = TranscriptionJob.objects.get(episode=self.ep)
|
||||
self.assertIsNone(job.data)
|
||||
self.kick.assert_not_called()
|
||||
|
||||
@patch('podcasts.transcription._too_large', return_value=False)
|
||||
def test_after_an_error_post_starts_again_at_the_back(self, _):
|
||||
old = TranscriptionJob.objects.create(episode=self.ep, state='error', error='Whisper: HTTP 500')
|
||||
r = self.post(self.url(), {'article': True})
|
||||
self.assertEqual((r.json()['state'], r.json()['error']), ('queued', ''))
|
||||
new = TranscriptionJob.objects.get(episode=self.ep)
|
||||
self.assertGreater(new.pk, old.pk)
|
||||
|
||||
@patch('podcasts.transcription._too_large', return_value=False)
|
||||
def test_delete_cancels_queued_and_running_but_not_a_finished_one(self, _):
|
||||
self.post(self.url())
|
||||
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
|
||||
self.assertFalse(TranscriptionJob.objects.exists())
|
||||
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=5, chunks_done=2)
|
||||
self.assertEqual(self.client.delete(self.url()).json()['state'], 'none')
|
||||
self.assertFalse(TranscriptionJob.objects.exists())
|
||||
self.done_job(self.ep)
|
||||
self.assertEqual(self.client.delete(self.url()).json()['state'], 'done')
|
||||
self.assertTrue(TranscriptionJob.objects.exists())
|
||||
|
||||
def test_get_shows_progress(self):
|
||||
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', chunks_total=38, chunks_done=7,
|
||||
started_at=timezone.now())
|
||||
d = self.client.get(self.url()).json()
|
||||
self.assertEqual((d['state'], d['chunks_done'], d['chunks_total']), ('transcribing', 7, 38))
|
||||
self.assertIsNotNone(d['started_at'])
|
||||
self.kick.assert_called() # a restart's first request starts the queue again
|
||||
|
||||
|
||||
class TranscriptTests(EndpointBase):
|
||||
def transcript(self, ep=None):
|
||||
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/transcript/')
|
||||
|
||||
def detail(self, ep=None):
|
||||
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/').json()
|
||||
|
||||
def test_404_as_before_without_any(self):
|
||||
self.assertEqual((self.transcript().status_code, self.transcript().json()), (404, {'error': 'no transcript'}))
|
||||
d = self.detail()
|
||||
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription'], d['article_item_id']),
|
||||
(False, None, None, None))
|
||||
|
||||
def test_falls_back_to_the_whisper_transcript(self):
|
||||
job = self.done_job(self.ep)
|
||||
r = self.transcript()
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json(), job.data)
|
||||
d = self.detail()
|
||||
self.assertEqual((d['has_transcript'], d['transcript_origin']), (True, 'whisper'))
|
||||
self.assertEqual(d['transcription']['state'], 'done')
|
||||
|
||||
def test_a_running_job_has_no_transcript_yet(self):
|
||||
TranscriptionJob.objects.create(episode=self.ep, state='transcribing', data=None)
|
||||
self.assertEqual(self.transcript().status_code, 404)
|
||||
d = self.detail()
|
||||
self.assertEqual((d['has_transcript'], d['transcript_origin'], d['transcription']['state']),
|
||||
(False, None, 'transcribing'))
|
||||
|
||||
def feed_transcript(self, error=''):
|
||||
ep = self.ep
|
||||
ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
|
||||
ep.save()
|
||||
EpisodeTranscript.objects.create(
|
||||
episode=ep, sources=['https://example.org/t.vtt'], error=error,
|
||||
data=None if error else {'segments': [{'start': 0, 'end': 1, 'speaker': 'A', 'text': 'Aus dem Feed'}],
|
||||
'source': {'url': 'https://example.org/t.vtt'}})
|
||||
|
||||
def test_the_feeds_transcript_wins(self):
|
||||
self.done_job(self.ep)
|
||||
self.feed_transcript()
|
||||
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Aus dem Feed')
|
||||
self.assertEqual(self.detail()['transcript_origin'], 'feed')
|
||||
|
||||
def test_an_unusable_feed_transcript_gives_way(self):
|
||||
self.done_job(self.ep)
|
||||
self.feed_transcript(error='invalid')
|
||||
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
|
||||
self.assertEqual(self.detail()['transcript_origin'], 'whisper')
|
||||
|
||||
def test_a_feed_transcript_that_cannot_be_loaded_now_gives_way_too(self):
|
||||
self.done_job(self.ep)
|
||||
self.ep.transcripts = [{'url': 'https://example.org/t.vtt', 'type': 'text/vtt'}]
|
||||
self.ep.save()
|
||||
from . import chapters
|
||||
with patch('podcasts.views.transcripts_mod.load', side_effect=chapters.FetchUnavailable('x')):
|
||||
self.assertEqual(self.transcript().json()['segments'][0]['text'], 'Hallo.')
|
||||
|
||||
def test_article_item_id(self):
|
||||
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='Podcast-Artikel',
|
||||
kind='blog')
|
||||
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
|
||||
published=timezone.now())
|
||||
EpisodeArticle.objects.create(episode=self.ep, item=item)
|
||||
self.assertEqual(self.detail()['article_item_id'], item.id)
|
||||
|
||||
|
||||
class WishTests(EndpointBase):
|
||||
PENDING = '/podcasts/transcription/pending/'
|
||||
|
||||
def claim(self, ep=None, method='post', **kw):
|
||||
return getattr(self.client, method)(
|
||||
f'/podcasts/episodes/{(ep or self.ep).id}/article/claim/', json.dumps(kw.pop('body', {})),
|
||||
content_type='application/json', **kw)
|
||||
|
||||
def test_pending_lists_open_wishes_only(self):
|
||||
wished = self.done_job(self.ep, want_article=True)
|
||||
e2, e3, e4, e5 = (self.episode(g) for g in ('e2', 'e3', 'e4', 'e5'))
|
||||
self.done_job(e2) # no wish
|
||||
TranscriptionJob.objects.create(episode=e3, state='transcribing', want_article=True) # not done
|
||||
self.done_job(e4, want_article=True, claimed_by='tablet', claimed_until=timezone.now() + timedelta(minutes=5))
|
||||
job5 = self.done_job(e5, want_article=True)
|
||||
source = Source.objects.create(user=self.user, url='diora://podcast-artikel', title='P', kind='blog')
|
||||
item = Item.objects.create(source=source, user=self.user, kind='blog', guid_hash='x', title='A',
|
||||
published=timezone.now())
|
||||
EpisodeArticle.objects.create(episode=e5, item=item) # written already
|
||||
r = self.client.get(self.PENDING).json()
|
||||
self.assertEqual(r, [{'episode_id': self.ep.id, 'title': 'Folge e1', 'podcast': 'Der Podcast',
|
||||
'duration_seconds': 600}])
|
||||
self.assertTrue(wished.want_article and job5.want_article)
|
||||
|
||||
def test_pending_is_per_user_and_needs_login(self):
|
||||
self.done_job(self.ep, want_article=True)
|
||||
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||
self.client.force_login(other)
|
||||
self.assertEqual(self.client.get(self.PENDING).json(), [])
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get(self.PENDING).status_code, 401)
|
||||
|
||||
def test_an_expired_claim_is_open_again(self):
|
||||
self.done_job(self.ep, want_article=True, claimed_by='tablet',
|
||||
claimed_until=timezone.now() - timedelta(seconds=1))
|
||||
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
|
||||
|
||||
def test_claim_blocks_the_other_device_until_it_expires(self):
|
||||
job = self.done_job(self.ep, want_article=True)
|
||||
r = self.claim(body={'device': 'phone'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()['claimed_by'], 'phone')
|
||||
job.refresh_from_db()
|
||||
left = job.claimed_until - timezone.now()
|
||||
self.assertTrue(timedelta(minutes=14) < left <= timedelta(minutes=15))
|
||||
self.assertEqual(self.client.get(self.PENDING).json(), [])
|
||||
r = self.claim(body={'device': 'desktop'})
|
||||
self.assertEqual(r.status_code, 409)
|
||||
self.assertEqual(r.json()['error'], 'claimed')
|
||||
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 200) # the holder renews
|
||||
TranscriptionJob.objects.filter(pk=job.pk).update(claimed_until=timezone.now() - timedelta(seconds=1))
|
||||
self.assertEqual(self.claim(body={'device': 'desktop'}).status_code, 200)
|
||||
job.refresh_from_db()
|
||||
self.assertEqual(job.claimed_by, 'desktop')
|
||||
|
||||
def test_the_device_may_come_in_a_header(self):
|
||||
self.done_job(self.ep, want_article=True)
|
||||
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='phone').status_code, 200)
|
||||
self.assertEqual(self.claim(HTTP_X_DIORA_DEVICE='desktop').status_code, 409)
|
||||
|
||||
def test_a_device_name_is_required(self):
|
||||
self.done_job(self.ep, want_article=True)
|
||||
self.assertEqual(self.claim().status_code, 400)
|
||||
|
||||
def test_release(self):
|
||||
self.done_job(self.ep, want_article=True)
|
||||
self.claim(body={'device': 'phone'})
|
||||
self.assertEqual(self.claim(method='delete', body={'device': 'desktop'}).status_code, 409)
|
||||
self.assertEqual(self.claim(method='delete', body={'device': 'phone'}).status_code, 200)
|
||||
self.assertEqual(len(self.client.get(self.PENDING).json()), 1)
|
||||
|
||||
def test_no_wish_no_claim(self):
|
||||
self.done_job(self.ep) # done, but no article wanted
|
||||
self.assertEqual(self.claim(body={'device': 'phone'}).status_code, 404)
|
||||
self.assertEqual(self.claim(self.episode('e9'), body={'device': 'phone'}).status_code, 404)
|
||||
|
||||
|
||||
class NotificationTests(EndpointBase):
|
||||
def get(self, **q):
|
||||
return self.client.get('/feeds/notifications/', q).json()
|
||||
|
||||
def test_first_time_only_the_cursor(self):
|
||||
j = self.done_job(self.ep, want_article=True)
|
||||
d = self.get()
|
||||
self.assertEqual((d['transcripts'], d['t_last'], d['items']), ([], j.id, []))
|
||||
self.assertEqual(d['last'], 0)
|
||||
|
||||
def test_jobs_done_after_the_cursor(self):
|
||||
e2, e3 = self.episode('e2'), self.episode('e3')
|
||||
a = self.done_job(self.ep)
|
||||
b = self.done_job(e2, want_article=True)
|
||||
TranscriptionJob.objects.create(episode=e3, state='transcribing') # not done: no notice
|
||||
d = self.get(t_after=a.id)
|
||||
self.assertEqual(d['transcripts'], [{'job_id': b.id, 'episode_id': e2.id, 'title': 'Folge e2',
|
||||
'podcast': 'Der Podcast', 'want_article': True}])
|
||||
self.assertEqual(d['t_last'], b.id)
|
||||
self.assertEqual(self.get(t_after=b.id)['transcripts'], [])
|
||||
self.assertEqual([t['job_id'] for t in self.get(t_after=0)['transcripts']], [a.id, b.id])
|
||||
|
||||
def test_only_the_users_own_and_not_feed_only_jobs(self):
|
||||
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||
feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/o', title='O')
|
||||
ep = PodcastEpisode.objects.create(feed=feed, guid='o', title='O', audio_url='https://example.org/o.mp3')
|
||||
self.done_job(ep)
|
||||
self.done_job(self.episode('e2'), data=None) # only the wish for an article from a feed transcript
|
||||
d = self.get(t_after=0)
|
||||
self.assertEqual((d['transcripts'], d['t_last']), ([], 0))
|
||||
|
||||
def test_items_still_work_beside_it(self):
|
||||
d = self.get(after=0)
|
||||
self.assertEqual((d['items'], d['last'], d['transcripts'], d['t_last']), ([], 0, [], 0))
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import io
|
||||
import json
|
||||
import wave
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from feeds import logins
|
||||
|
||||
from . import chapters, transcription
|
||||
from .models import TranscriptionSettings
|
||||
|
||||
SETTINGS = '/podcasts/transcription/settings/'
|
||||
TEST = '/podcasts/transcription/test/'
|
||||
|
||||
|
||||
def fake_response(status=200):
|
||||
r = MagicMock()
|
||||
r.status_code = status
|
||||
r.headers = {}
|
||||
r.raw = None # nothing to introspect for the peer check
|
||||
return r
|
||||
|
||||
|
||||
class SettingsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def post(self, data, url=SETTINGS):
|
||||
return self.client.post(url, json.dumps(data), content_type='application/json')
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get(SETTINGS).status_code, 401)
|
||||
self.assertEqual(self.post({}, TEST).status_code, 401)
|
||||
|
||||
def test_defaults(self):
|
||||
self.assertEqual(self.client.get(SETTINGS).json(), {
|
||||
'engine': 'builtin', 'url': '', 'model': '', 'language': '', 'parallel': 3,
|
||||
'has_token': False, 'builtin_available': False})
|
||||
self.assertFalse(TranscriptionSettings.objects.exists())
|
||||
|
||||
@override_settings(WHISPER_URL='http://whisper:8000/v1')
|
||||
def test_builtin_available_follows_the_environment_setting(self):
|
||||
self.assertTrue(self.client.get(SETTINGS).json()['builtin_available'])
|
||||
|
||||
def test_roundtrip(self):
|
||||
r = self.post({'engine': 'custom', 'url': 'https://whisper.example.org/v1/', 'model': 'large-v3',
|
||||
'language': 'de', 'parallel': 5, 'token': 'sk-secret'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
want = {'engine': 'custom', 'url': 'https://whisper.example.org/v1', 'model': 'large-v3',
|
||||
'language': 'de', 'parallel': 5, 'has_token': True, 'builtin_available': False}
|
||||
self.assertEqual(r.json(), want)
|
||||
self.assertEqual(self.client.get(SETTINGS).json(), want)
|
||||
|
||||
def test_token_is_write_only_and_stored_encrypted(self):
|
||||
self.post({'engine': 'custom', 'url': 'https://w.example.org/v1', 'token': 'sk-secret'})
|
||||
conf = TranscriptionSettings.objects.get(user=self.user)
|
||||
self.assertTrue(conf.token_encrypted)
|
||||
self.assertNotIn('sk-secret', conf.token_encrypted)
|
||||
self.assertEqual(transcription.token_of(conf), 'sk-secret')
|
||||
for body in (self.client.get(SETTINGS).content, self.post({'model': 'm'}).content):
|
||||
self.assertNotIn(b'sk-secret', body)
|
||||
self.assertNotIn(conf.token_encrypted.encode(), body)
|
||||
# Not sending a token keeps it; an empty one removes it.
|
||||
conf.refresh_from_db()
|
||||
self.assertTrue(conf.token_encrypted)
|
||||
self.assertFalse(self.post({'token': ''}).json()['has_token'])
|
||||
conf.refresh_from_db()
|
||||
self.assertEqual(conf.token_encrypted, '')
|
||||
|
||||
def test_validation(self):
|
||||
bad = [
|
||||
{'engine': 'cloud'}, {'parallel': 0}, {'parallel': 9}, {'parallel': '3'}, {'parallel': True},
|
||||
{'url': 'ftp://x.org/v1'}, {'url': 'javascript:alert(1)'}, {'url': 'whisper.local/v1'},
|
||||
{'language': 'Deutsch'}, {'model': 5}, {'token': 5},
|
||||
{'engine': 'custom'}, # a custom instance needs a url
|
||||
]
|
||||
for body in bad:
|
||||
self.assertEqual(self.post(body).status_code, 400, body)
|
||||
self.assertEqual(self.client.post(SETTINGS, 'nope', content_type='application/json').status_code, 400)
|
||||
self.assertFalse(TranscriptionSettings.objects.exists())
|
||||
for body in ({'parallel': 1}, {'parallel': 8}, {'language': ''}, {'language': 'pt-BR'}):
|
||||
self.assertEqual(self.post(body).status_code, 200, body)
|
||||
|
||||
def test_settings_are_per_user(self):
|
||||
self.post({'parallel': 7})
|
||||
other = User.objects.create_user(username='bob', password='pw12345678')
|
||||
self.client.force_login(other)
|
||||
self.assertEqual(self.client.get(SETTINGS).json()['parallel'], 3)
|
||||
|
||||
|
||||
class TestEndpointTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def configure(self, **kw):
|
||||
TranscriptionSettings.objects.update_or_create(user=self.user, defaults=kw)
|
||||
|
||||
def test_custom_sends_a_second_of_silence(self):
|
||||
self.configure(engine='custom', url='https://whisper.example.org/v1', model='large-v3', language='de',
|
||||
token_encrypted=logins.encrypt('sk-secret'))
|
||||
calls = []
|
||||
|
||||
def post(url, **kw):
|
||||
calls.append((url, kw))
|
||||
return fake_response()
|
||||
|
||||
with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.post', post):
|
||||
r = self.client.post(TEST)
|
||||
self.assertEqual(r.json(), {'ok': True, 'model': 'large-v3', 'error': ''})
|
||||
url, kw = calls[0]
|
||||
self.assertEqual(url, 'https://whisper.example.org/v1/audio/transcriptions')
|
||||
self.assertEqual(kw['headers']['Authorization'], 'Bearer sk-secret')
|
||||
self.assertEqual(kw['data']['model'], 'large-v3')
|
||||
self.assertEqual(kw['data']['language'], 'de')
|
||||
name, wav, mime = kw['files']['file']
|
||||
self.assertEqual(mime, 'audio/wav')
|
||||
with wave.open(io.BytesIO(wav)) as w:
|
||||
self.assertEqual((w.getframerate(), w.getnchannels(), w.getnframes()), (16000, 1, 16000))
|
||||
|
||||
def test_custom_private_address_is_refused(self):
|
||||
self.configure(engine='custom', url='http://127.0.0.1:9000/v1')
|
||||
with patch('podcasts.chapters.requests.post') as post:
|
||||
r = self.client.post(TEST)
|
||||
self.assertFalse(r.json()['ok'])
|
||||
self.assertTrue(r.json()['error'])
|
||||
post.assert_not_called()
|
||||
|
||||
def test_custom_http_error_is_reported(self):
|
||||
self.configure(engine='custom', url='https://whisper.example.org/v1')
|
||||
with patch('podcasts.chapters.assert_safe_url'), \
|
||||
patch('podcasts.chapters.requests.post', return_value=fake_response(401)):
|
||||
r = self.client.post(TEST)
|
||||
self.assertEqual((r.json()['ok'], r.json()['error']), (False, 'HTTP 401'))
|
||||
|
||||
@override_settings(WHISPER_URL='http://whisper:8000/v1')
|
||||
def test_builtin_is_exempt_from_the_address_check(self):
|
||||
self.configure(engine='builtin')
|
||||
with patch('podcasts.transcription.requests.post', return_value=fake_response()) as post, \
|
||||
patch('podcasts.chapters.assert_safe_url', side_effect=chapters.WebDAVInputError('private')):
|
||||
r = self.client.post(TEST)
|
||||
self.assertTrue(r.json()['ok'])
|
||||
self.assertEqual(post.call_args.args[0], 'http://whisper:8000/v1/audio/transcriptions')
|
||||
self.assertNotIn('Authorization', post.call_args.kwargs['headers'])
|
||||
|
||||
def test_builtin_without_instance(self):
|
||||
r = self.client.post(TEST)
|
||||
self.assertFalse(r.json()['ok'])
|
||||
self.assertTrue(r.json()['error'])
|
||||
|
|
@ -837,7 +837,7 @@ class EpisodeListTests(TestCase):
|
|||
a2 = dict(eps[self.a2.id])
|
||||
self.assertTrue(a2.pop('discovered_at')) # when diora found it (the Strom's mark)
|
||||
self.assertEqual(a2, {
|
||||
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'link': '', 'duration_seconds': 0,
|
||||
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0,
|
||||
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
|
||||
'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,
|
||||
|
|
|
|||
|
|
@ -1,431 +0,0 @@
|
|||
"""Transcribe an episode with a Whisper instance (docs/transcription.md, "Transcribing").
|
||||
|
||||
The server downloads the episode (as silence.py does, with the same size limit), decodes it
|
||||
once to 16 kHz mono, cuts it into pieces of about eight minutes where there is a pause (so no
|
||||
word is cut), sends the pieces in parallel to the instance's OpenAI-compatible
|
||||
`/audio/transcriptions` and joins the answers into one transcript, which is kept on the job
|
||||
(TranscriptionJob.data).
|
||||
|
||||
A job runs in a daemon thread, one at a time per server; the others wait as `queued` and are
|
||||
taken oldest first (by id). A job found `downloading` or `transcribing` when the process has
|
||||
just started (a restart killed it) is queued again. Cancelling deletes the job's row: the
|
||||
worker notices when it next writes progress and stops.
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db import close_old_connections
|
||||
from django.utils import timezone
|
||||
|
||||
from . import chapters, silence, transcripts
|
||||
from .models import TranscriptionJob, TranscriptionSettings
|
||||
from .transcription import DEFAULT_MODEL, for_user, token_of
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SECONDS = 8 * 60 # about this long a piece
|
||||
CUT_WINDOW = 30 # a cut moves to the longest pause within this many seconds
|
||||
MIN_TAIL = 60 # a last piece shorter than this is joined to the one before
|
||||
RATE = 16000 # Hz, mono, 16-bit
|
||||
WINDOW = silence.WINDOW
|
||||
RETRIES = 3
|
||||
RETRY_PAUSES = (3, 10, 30) # seconds before each retry
|
||||
UPLOAD_TIMEOUT = (10, 3600) # a CPU instance may need many minutes for a piece
|
||||
DECODE_TIMEOUT = 3600
|
||||
OPUS_BITRATE = '32k'
|
||||
MAX_PROMPT = 500 # characters
|
||||
ALIVE_CHECK = 5 # seconds between looks whether the job was cancelled
|
||||
WAIT_RETRY = 600 # seconds between tries while the built-in instance can't be reached
|
||||
WAIT_MAX = 48 * 3600 # after this long since the button was pressed a waiting job gives up
|
||||
WAIT_IDLE = 30 # seconds the runner sleeps while every queued job is waiting
|
||||
|
||||
_lock = threading.Lock()
|
||||
_runner = None # the thread working through the queue, or None
|
||||
_resumed = False # whether jobs left over from before a restart were queued again
|
||||
_not_before = {} # job id -> time.time() before which a waiting job isn't tried again
|
||||
WAIT = 'wait' # _next_job: jobs are queued, but none is due yet
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
"""The job's row is gone: the user cancelled it."""
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
"""The job can't succeed; the message is shown to the user."""
|
||||
|
||||
|
||||
class Unreachable(Exception):
|
||||
"""The built-in instance can't be reached (e.g. the machine it runs on is off): the job waits
|
||||
and tries again later instead of failing."""
|
||||
|
||||
|
||||
class Permanent(Exception):
|
||||
"""A request the instance refused for good (a client error): not worth a retry."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def kick():
|
||||
"""Make sure queued jobs are being worked on. Called by the endpoints, so after a restart
|
||||
the first request brings back what was running."""
|
||||
global _runner, _resumed
|
||||
with _lock:
|
||||
if not _resumed:
|
||||
_resumed = True
|
||||
TranscriptionJob.objects.filter(
|
||||
state__in=[TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING],
|
||||
).update(state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0)
|
||||
if _runner is not None:
|
||||
return
|
||||
if not TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED).exists():
|
||||
return
|
||||
_runner = threading.Thread(target=_loop, name='transcribe', daemon=True)
|
||||
_runner.start()
|
||||
|
||||
|
||||
def _next_job():
|
||||
global _runner
|
||||
with _lock:
|
||||
queued = list(TranscriptionJob.objects.filter(state=TranscriptionJob.QUEUED)
|
||||
.order_by('id').values_list('id', flat=True))
|
||||
if not queued:
|
||||
_runner = None # decided under the lock, so a job queued right now starts a new runner
|
||||
return None
|
||||
now = time.time()
|
||||
due = next((i for i in queued if _not_before.get(i, 0) <= now), None)
|
||||
return due if due is not None else WAIT
|
||||
|
||||
|
||||
def _loop():
|
||||
global _runner
|
||||
try:
|
||||
while True:
|
||||
close_old_connections()
|
||||
job_id = _next_job()
|
||||
if job_id is None:
|
||||
return
|
||||
if job_id == WAIT:
|
||||
time.sleep(WAIT_IDLE)
|
||||
continue
|
||||
try:
|
||||
run(job_id)
|
||||
except Exception: # run() reports its own failures; this keeps the queue going
|
||||
log.exception('transcription job %s crashed', job_id)
|
||||
except BaseException:
|
||||
with _lock:
|
||||
_runner = None
|
||||
raise
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
|
||||
def run(job_id):
|
||||
"""Work one job through to `done` or `error`, in this thread."""
|
||||
started = TranscriptionJob.objects.filter(pk=job_id, state=TranscriptionJob.QUEUED).update(
|
||||
state=TranscriptionJob.DOWNLOADING, started_at=timezone.now(), finished_at=None,
|
||||
error='', chunks_done=0, chunks_total=0)
|
||||
if not started:
|
||||
return # cancelled while it waited
|
||||
try:
|
||||
data = _transcribe(job_id)
|
||||
_write(job_id, state=TranscriptionJob.DONE, data=data, error='', finished_at=timezone.now())
|
||||
except Cancelled:
|
||||
log.info('transcription job %s cancelled', job_id)
|
||||
except Unreachable as e:
|
||||
_wait(job_id, str(e))
|
||||
except Failed as e:
|
||||
_fail(job_id, str(e))
|
||||
except Exception as e:
|
||||
log.exception('transcription job %s failed', job_id)
|
||||
_fail(job_id, f'{type(e).__name__}: {e}')
|
||||
|
||||
|
||||
def _wait(job_id, reason):
|
||||
"""Back into the queue to try again in WAIT_RETRY seconds, or give up when it waited too long."""
|
||||
job = TranscriptionJob.objects.filter(pk=job_id).only('created_at').first()
|
||||
if job is None:
|
||||
return
|
||||
if (timezone.now() - job.created_at).total_seconds() > WAIT_MAX:
|
||||
_not_before.pop(job_id, None)
|
||||
_fail(job_id, 'Whisper-Instanz zwei Tage lang nicht erreichbar')
|
||||
return
|
||||
_not_before[job_id] = time.time() + WAIT_RETRY
|
||||
again = timezone.localtime(timezone.now() + datetime.timedelta(seconds=WAIT_RETRY)).strftime('%H:%M')
|
||||
log.info('transcription job %s waits for the instance (%s)', job_id, reason)
|
||||
TranscriptionJob.objects.filter(pk=job_id).update(
|
||||
state=TranscriptionJob.QUEUED, chunks_done=0, chunks_total=0, started_at=None,
|
||||
error=f'Whisper-Instanz nicht erreichbar, neuer Versuch um {again}')
|
||||
|
||||
|
||||
def _probe(url):
|
||||
"""Unreachable unless something answers at the built-in instance's address."""
|
||||
try:
|
||||
requests.get(url.rsplit('/audio/transcriptions', 1)[0], timeout=(5, 10)).close()
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
raise Unreachable(e.__class__.__name__)
|
||||
|
||||
|
||||
def _fail(job_id, message):
|
||||
TranscriptionJob.objects.filter(pk=job_id).update(
|
||||
state=TranscriptionJob.ERROR, error=(message or 'failed')[:500], finished_at=timezone.now())
|
||||
|
||||
|
||||
def _write(job_id, **fields):
|
||||
"""Update the job; Cancelled when its row is gone."""
|
||||
if not TranscriptionJob.objects.filter(pk=job_id).update(**fields):
|
||||
raise Cancelled()
|
||||
|
||||
|
||||
def _alive(job_id):
|
||||
if not TranscriptionJob.objects.filter(pk=job_id).exists():
|
||||
raise Cancelled()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _target(conf):
|
||||
"""(url, headers, builtin) of the instance the settings point at."""
|
||||
builtin = conf.engine == TranscriptionSettings.BUILTIN
|
||||
base = settings.WHISPER_URL if builtin else conf.url
|
||||
if not base:
|
||||
raise Failed('Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen')
|
||||
headers = {'User-Agent': 'diora transcription'}
|
||||
if not builtin and conf.token_encrypted:
|
||||
headers['Authorization'] = 'Bearer ' + token_of(conf)
|
||||
return base.rstrip('/') + '/audio/transcriptions', headers, builtin
|
||||
|
||||
|
||||
def _prompt(episode):
|
||||
parts = [episode.feed.title, episode.title, episode.feed.author]
|
||||
return '. '.join(p.strip() for p in parts if p and p.strip())[:MAX_PROMPT]
|
||||
|
||||
|
||||
def _transcribe(job_id):
|
||||
job = TranscriptionJob.objects.select_related('episode__feed').get(pk=job_id)
|
||||
episode = job.episode
|
||||
conf = for_user(episode.feed.user)
|
||||
url, headers, builtin = _target(conf)
|
||||
if builtin:
|
||||
_probe(url) # before downloading: is the instance there at all?
|
||||
options = {'model': conf.model or DEFAULT_MODEL, 'response_format': 'verbose_json'}
|
||||
if conf.language:
|
||||
options['language'] = conf.language
|
||||
prompt = _prompt(episode)
|
||||
if prompt:
|
||||
options['prompt'] = prompt
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='diora-transcribe-') as tmp:
|
||||
source = os.path.join(tmp, 'episode.audio')
|
||||
_download(episode.audio_url, source, job_id)
|
||||
pcm = os.path.join(tmp, 'episode.pcm')
|
||||
_decode(source, pcm)
|
||||
os.remove(source)
|
||||
total = os.path.getsize(pcm) // 2 / RATE
|
||||
if total <= 0:
|
||||
raise Failed('Kein Ton in der Datei')
|
||||
pieces = plan(total, pauses_of(pcm))
|
||||
_write(job_id, state=TranscriptionJob.TRANSCRIBING, chunks_total=len(pieces), chunks_done=0)
|
||||
|
||||
results = {}
|
||||
stop = threading.Event() # set when the job ends early: no piece starts after that
|
||||
suffix, mime = ('.flac', 'audio/flac') if builtin else ('.ogg', 'audio/ogg')
|
||||
|
||||
def work(index):
|
||||
# Only the HTTP side runs here; the database is touched by this thread alone.
|
||||
if stop.is_set():
|
||||
return index, None
|
||||
start, end = pieces[index]
|
||||
path = _encode(pcm, start, end, tmp, index, opus=not builtin)
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
body = f.read()
|
||||
finally:
|
||||
os.remove(path)
|
||||
return index, _send(url, headers, builtin, f'piece-{index}{suffix}', mime, body, options, stop)
|
||||
|
||||
pool = ThreadPoolExecutor(max_workers=max(1, min(conf.parallel, 8)))
|
||||
try:
|
||||
pending = {pool.submit(work, i) for i in range(len(pieces))}
|
||||
while pending:
|
||||
finished, pending = wait(pending, timeout=ALIVE_CHECK, return_when=FIRST_COMPLETED)
|
||||
_alive(job_id) # also while a long piece is out
|
||||
for f in finished:
|
||||
index, answer = f.result() # the first failure ends the job
|
||||
results[index] = answer
|
||||
if finished:
|
||||
_write(job_id, chunks_done=len(results))
|
||||
finally:
|
||||
stop.set()
|
||||
pool.shutdown(wait=True, cancel_futures=True)
|
||||
return build(pieces, [results[i] for i in range(len(pieces))], options['model'], conf.language)
|
||||
|
||||
|
||||
def _download(url, path, job_id):
|
||||
size = 0
|
||||
with open(path, 'wb') as out, requests.get(
|
||||
url, stream=True, timeout=(10, 60), headers={'User-Agent': 'diora transcription'}) as r:
|
||||
if r.status_code >= 400:
|
||||
raise Failed(f'Download: HTTP {r.status_code}')
|
||||
if int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES:
|
||||
raise Failed('too large')
|
||||
for n, chunk in enumerate(r.iter_content(1 << 20)):
|
||||
size += len(chunk)
|
||||
if size > silence.MAX_BYTES:
|
||||
raise Failed('too large')
|
||||
out.write(chunk)
|
||||
if n % 8 == 0:
|
||||
_alive(job_id)
|
||||
if size == 0:
|
||||
raise Failed('Download: leere Datei')
|
||||
|
||||
|
||||
def _run_ffmpeg(args, input=None, timeout=DECODE_TIMEOUT):
|
||||
import imageio_ffmpeg
|
||||
proc = subprocess.run(
|
||||
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-loglevel', 'error', *args],
|
||||
input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||
if proc.returncode != 0:
|
||||
err = proc.stderr.decode('utf-8', 'replace').strip().splitlines()
|
||||
raise Failed('ffmpeg: ' + (err[-1] if err else f'exit {proc.returncode}')[:200])
|
||||
|
||||
|
||||
def _decode(source, pcm):
|
||||
"""The audio at `source` as raw 16 kHz mono 16-bit samples in the file `pcm`."""
|
||||
_run_ffmpeg(['-vn', '-sn', '-dn', '-i', source, '-ac', '1', '-ar', str(RATE), '-f', 's16le', '-y', pcm])
|
||||
|
||||
|
||||
def pauses_of(pcm):
|
||||
"""The pauses of the decoded audio as [(middle, length)] in seconds, found with
|
||||
silence.py's rules (what it cuts out of a pause is centred on the pause)."""
|
||||
size = int(RATE * WINDOW) * 2
|
||||
levels = []
|
||||
with open(pcm, 'rb') as f:
|
||||
while True:
|
||||
data = f.read(size * 100000)
|
||||
if not data:
|
||||
break
|
||||
for i in range(0, len(data) - size + 1, size):
|
||||
levels.append(silence._level_db(data[i:i + size]))
|
||||
return [((a + b) / 2, b - a) for a, b in silence.cuts(levels)]
|
||||
|
||||
|
||||
def plan(total, pauses):
|
||||
"""[(start, end)] in seconds covering 0..total: pieces of about CHUNK_SECONDS, each cut at
|
||||
the longest pause within CUT_WINDOW of where it would fall (right there when there is none)."""
|
||||
cuts = []
|
||||
last = 0.0
|
||||
k = 1
|
||||
while k * CHUNK_SECONDS < total - MIN_TAIL:
|
||||
target = k * CHUNK_SECONDS
|
||||
k += 1
|
||||
near = [p for p in pauses if abs(p[0] - target) <= CUT_WINDOW and p[0] > last + 1]
|
||||
at = max(near, key=lambda p: p[1])[0] if near else target
|
||||
if at <= last or at >= total:
|
||||
continue
|
||||
cuts.append(at)
|
||||
last = at
|
||||
edges = [0.0, *cuts, total]
|
||||
return [(edges[i], edges[i + 1]) for i in range(len(edges) - 1)]
|
||||
|
||||
|
||||
def _encode(pcm, start, end, tmp, index, opus):
|
||||
"""The piece [start, end) of the decoded audio as FLAC or Opus (ogg), in a file."""
|
||||
with open(pcm, 'rb') as f:
|
||||
f.seek(int(round(start * RATE)) * 2)
|
||||
data = f.read(int(round((end - start) * RATE)) * 2)
|
||||
out = os.path.join(tmp, f'piece-{index}.' + ('ogg' if opus else 'flac'))
|
||||
codec = ['-c:a', 'libopus', '-b:a', OPUS_BITRATE, '-f', 'ogg'] if opus else ['-c:a', 'flac', '-f', 'flac']
|
||||
_run_ffmpeg(['-f', 's16le', '-ar', str(RATE), '-ac', '1', '-i', 'pipe:0', *codec, '-y', out], input=data)
|
||||
return out
|
||||
|
||||
|
||||
def _post(url, headers, builtin, name, mime, body, options):
|
||||
"""One request; the parsed answer. Permanent for a refusal that won't change, RuntimeError
|
||||
for trouble that may pass."""
|
||||
files = {'file': (name, body, mime)}
|
||||
try:
|
||||
if builtin:
|
||||
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
|
||||
try:
|
||||
r = requests.post(url, headers=headers, files=files, data=options, timeout=UPLOAD_TIMEOUT)
|
||||
except requests.ConnectionError as e:
|
||||
raise Unreachable(e.__class__.__name__)
|
||||
if r.status_code >= 400:
|
||||
if r.status_code < 500 and r.status_code not in (408, 429):
|
||||
raise Permanent(f'HTTP {r.status_code}')
|
||||
raise RuntimeError(f'HTTP {r.status_code}')
|
||||
else:
|
||||
try:
|
||||
r = chapters._open(url, headers, method='POST', timeout=UPLOAD_TIMEOUT, files=files, data=options)
|
||||
except chapters.ChaptersInvalid as e:
|
||||
raise Permanent(str(e))
|
||||
except chapters.ChaptersUnavailable as e:
|
||||
raise RuntimeError(str(e))
|
||||
try:
|
||||
return r.json()
|
||||
finally:
|
||||
r.close()
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(e.__class__.__name__)
|
||||
except ValueError:
|
||||
raise RuntimeError('Antwort ist kein JSON')
|
||||
|
||||
|
||||
def _send(url, headers, builtin, name, mime, body, options, stop):
|
||||
"""A piece to the instance, retried up to RETRIES times with a growing pause."""
|
||||
for attempt in range(RETRIES + 1):
|
||||
if stop.is_set():
|
||||
raise Cancelled()
|
||||
try:
|
||||
return _post(url, headers, builtin, name, mime, body, options)
|
||||
except Permanent as e:
|
||||
raise Failed(f'Whisper: {e}')
|
||||
except Unreachable:
|
||||
if attempt >= RETRIES:
|
||||
raise
|
||||
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
|
||||
except RuntimeError as e:
|
||||
if attempt >= RETRIES:
|
||||
raise Failed(f'Whisper: {e}')
|
||||
log.info('whisper piece %s failed (%s), retrying', name, e)
|
||||
time.sleep(RETRY_PAUSES[min(attempt, len(RETRY_PAUSES) - 1)])
|
||||
|
||||
|
||||
def build(pieces, answers, model, language):
|
||||
"""{segments, source} from the pieces' places and the instance's verbose_json answers."""
|
||||
fragments = []
|
||||
detected = ''
|
||||
for (start, end), answer in zip(pieces, answers):
|
||||
if not isinstance(answer, dict):
|
||||
continue
|
||||
detected = detected or (answer.get('language') or '')
|
||||
segments = answer.get('segments')
|
||||
if not isinstance(segments, list): # a plain {"text": …} answer: one segment for the piece
|
||||
text = (answer.get('text') or '').strip()
|
||||
segments = [{'start': 0, 'end': end - start, 'text': text}] if text else []
|
||||
for s in segments:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
text = ' '.join(str(s.get('text') or '').split())
|
||||
try:
|
||||
a, b = float(s.get('start', 0)), float(s.get('end', 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if text:
|
||||
fragments.append((start + a, start + b, '', text))
|
||||
merged = transcripts.merge(fragments)
|
||||
if not merged:
|
||||
raise Failed('Kein Text erkannt')
|
||||
return {'segments': merged, 'source': {'type': 'whisper', 'model': model, 'language': language or detected}}
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
"""Transcription settings and the test of the configured Whisper instance
|
||||
(docs/transcription.md). The job itself is podcasts/transcribe.py."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import wave
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from feeds import logins
|
||||
|
||||
from . import chapters
|
||||
from .models import EpisodeArticle, PodcastEpisode, TranscriptionJob, TranscriptionSettings
|
||||
|
||||
DEFAULT_MODEL = 'whisper-1'
|
||||
TEST_TIMEOUT = 60 # a cold CPU instance may load its model first
|
||||
LANGUAGE_RE = re.compile(r'^[a-z]{2,3}(-[A-Za-z]{2,4})?$')
|
||||
|
||||
|
||||
def _auth(request):
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
return None
|
||||
|
||||
|
||||
def _body(request):
|
||||
try:
|
||||
body = json.loads(request.body or b'{}')
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
|
||||
|
||||
def for_user(user):
|
||||
"""The user's settings (unsaved defaults when there are none)."""
|
||||
return TranscriptionSettings.objects.filter(user=user).first() or TranscriptionSettings(user=user)
|
||||
|
||||
|
||||
def token_of(conf):
|
||||
"""The plain API token ('' when there is none or it can't be read)."""
|
||||
return (logins.decrypt(conf.token_encrypted) or '') if conf.token_encrypted else ''
|
||||
|
||||
|
||||
def _data(conf):
|
||||
return {
|
||||
'engine': conf.engine, 'url': conf.url, 'model': conf.model, 'language': conf.language,
|
||||
'parallel': conf.parallel, 'has_token': bool(conf.token_encrypted),
|
||||
'builtin_available': bool(settings.WHISPER_URL),
|
||||
}
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['GET', 'POST'])
|
||||
def transcription_settings(request):
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
conf = for_user(request.user)
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(_data(conf))
|
||||
body = _body(request)
|
||||
if body is None:
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
if 'engine' in body:
|
||||
if body['engine'] not in dict(TranscriptionSettings.ENGINES):
|
||||
return JsonResponse({'error': 'engine must be builtin or custom'}, status=400)
|
||||
conf.engine = body['engine']
|
||||
if 'url' in body:
|
||||
url = body['url']
|
||||
if not isinstance(url, str):
|
||||
return JsonResponse({'error': 'url must be text'}, status=400)
|
||||
url = url.strip().rstrip('/')
|
||||
if url and (urlsplit(url).scheme not in ('http', 'https') or not urlsplit(url).hostname):
|
||||
return JsonResponse({'error': 'url must start with http:// or https://'}, status=400)
|
||||
if len(url) > 500:
|
||||
return JsonResponse({'error': 'url too long'}, status=400)
|
||||
conf.url = url
|
||||
if 'model' in body:
|
||||
if not isinstance(body['model'], str) or len(body['model'].strip()) > 100:
|
||||
return JsonResponse({'error': 'invalid model'}, status=400)
|
||||
conf.model = body['model'].strip()
|
||||
if 'language' in body:
|
||||
lang = body['language']
|
||||
if not isinstance(lang, str) or (lang.strip() and not LANGUAGE_RE.match(lang.strip())):
|
||||
return JsonResponse({'error': 'language must be empty or an ISO code'}, status=400)
|
||||
conf.language = lang.strip()
|
||||
if 'parallel' in body:
|
||||
n = body['parallel']
|
||||
if isinstance(n, bool) or not isinstance(n, int) or not 1 <= n <= 8:
|
||||
return JsonResponse({'error': 'parallel must be 1 to 8'}, status=400)
|
||||
conf.parallel = n
|
||||
if 'token' in body:
|
||||
if not isinstance(body['token'], str):
|
||||
return JsonResponse({'error': 'token must be text'}, status=400)
|
||||
conf.token_encrypted = logins.encrypt(body['token']) if body['token'] else ''
|
||||
if conf.engine == TranscriptionSettings.CUSTOM and not conf.url:
|
||||
return JsonResponse({'error': 'a custom instance needs a url'}, status=400)
|
||||
conf.save()
|
||||
return JsonResponse(_data(conf))
|
||||
|
||||
|
||||
def silent_wav(seconds=1, rate=16000):
|
||||
"""`seconds` of 16-bit mono silence as a WAV file, made in memory."""
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, 'wb') as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(rate)
|
||||
w.writeframes(b'\0\0' * rate * seconds)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST'])
|
||||
def transcription_test(request):
|
||||
"""One second of silence to the configured instance: {ok, model, error}."""
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
conf = for_user(request.user)
|
||||
builtin = conf.engine == TranscriptionSettings.BUILTIN
|
||||
base = settings.WHISPER_URL if builtin else conf.url
|
||||
model = conf.model or DEFAULT_MODEL
|
||||
if not base:
|
||||
error = 'Keine eingebaute Instanz eingerichtet' if builtin else 'Keine Adresse eingetragen'
|
||||
return JsonResponse({'ok': False, 'model': model, 'error': error})
|
||||
url = base.rstrip('/') + '/audio/transcriptions'
|
||||
headers = {'User-Agent': 'diora transcription test'}
|
||||
if not builtin and conf.token_encrypted:
|
||||
headers['Authorization'] = 'Bearer ' + token_of(conf)
|
||||
files = {'file': ('silence.wav', silent_wav(), 'audio/wav')}
|
||||
data = {'model': model, 'response_format': 'json'}
|
||||
if conf.language:
|
||||
data['language'] = conf.language
|
||||
try:
|
||||
if builtin:
|
||||
# The operator's own instance (DIORA_WHISPER_URL) may sit on a private network.
|
||||
r = requests.post(url, headers=headers, files=files, data=data, timeout=TEST_TIMEOUT)
|
||||
if r.status_code >= 400:
|
||||
raise chapters.ChaptersInvalid(f'HTTP {r.status_code}')
|
||||
else:
|
||||
r = chapters._open(url, headers, method='POST', timeout=TEST_TIMEOUT, files=files, data=data)
|
||||
r.close()
|
||||
except (chapters.ChaptersInvalid, chapters.ChaptersUnavailable) as e:
|
||||
return JsonResponse({'ok': False, 'model': model, 'error': str(e)})
|
||||
except requests.RequestException as e:
|
||||
return JsonResponse({'ok': False, 'model': model, 'error': e.__class__.__name__})
|
||||
return JsonResponse({'ok': True, 'model': model, 'error': ''})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLAIM_MINUTES = 15
|
||||
ACTIVE = (TranscriptionJob.QUEUED, TranscriptionJob.DOWNLOADING, TranscriptionJob.TRANSCRIBING)
|
||||
|
||||
|
||||
def job_state(job):
|
||||
"""The job as the API shows it; state `none` when there is no job."""
|
||||
if job is None:
|
||||
return {'state': TranscriptionJob.NONE, 'chunks_done': 0, 'chunks_total': 0, 'error': '',
|
||||
'want_article': False, 'started_at': None, 'finished_at': None}
|
||||
return {
|
||||
'state': job.state, 'chunks_done': job.chunks_done, 'chunks_total': job.chunks_total,
|
||||
'error': job.error, 'want_article': job.want_article,
|
||||
'started_at': job.started_at.isoformat() if job.started_at else None,
|
||||
'finished_at': job.finished_at.isoformat() if job.finished_at else None,
|
||||
}
|
||||
|
||||
|
||||
def job_of(episode):
|
||||
return TranscriptionJob.objects.filter(episode=episode).first()
|
||||
|
||||
|
||||
def _too_large(url):
|
||||
"""Whether the server says the file at `url` is over the size limit (a failed look doesn't count)."""
|
||||
from . import silence
|
||||
try:
|
||||
r = requests.head(url, timeout=5, allow_redirects=True, headers={'User-Agent': 'diora transcription'})
|
||||
return int(r.headers.get('Content-Length') or 0) > silence.MAX_BYTES
|
||||
except (requests.RequestException, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['GET', 'POST', 'DELETE'])
|
||||
def episode_transcribe(request, pk):
|
||||
"""POST starts the episode's transcription (or just raises `want_article` on one that exists),
|
||||
GET tells how far it is, DELETE cancels a queued or running one."""
|
||||
from . import transcribe, views
|
||||
from .youtube import is_video
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
ep = PodcastEpisode.objects.select_related('feed').filter(pk=pk, feed__user=request.user).first()
|
||||
if ep is None:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
job = job_of(ep)
|
||||
|
||||
if request.method == 'GET':
|
||||
transcribe.kick() # after a restart the first request brings the work back
|
||||
return JsonResponse(job_state(job))
|
||||
|
||||
if request.method == 'DELETE':
|
||||
if job is not None and job.state != TranscriptionJob.DONE:
|
||||
job.delete() # a running worker notices its row is gone and stops
|
||||
job = None
|
||||
return JsonResponse(job_state(job))
|
||||
|
||||
body = _body(request)
|
||||
if body is None:
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
article = body.get('article') is True
|
||||
if is_video(ep.audio_url):
|
||||
return JsonResponse({'error': 'youtube'}, status=400)
|
||||
|
||||
has_feed = views._has_transcript(ep)
|
||||
if job is not None and (job.state in ACTIVE or (job.state == TranscriptionJob.DONE and (job.data or has_feed))):
|
||||
if article and not job.want_article:
|
||||
job.want_article = True
|
||||
job.save(update_fields=['want_article'])
|
||||
return JsonResponse(job_state(job))
|
||||
if has_feed: # the feed's own transcript is there: only the article is wanted
|
||||
if job is None:
|
||||
job = TranscriptionJob(episode=ep)
|
||||
job.state, job.want_article, job.finished_at = TranscriptionJob.DONE, article or job.want_article, timezone.now()
|
||||
job.save()
|
||||
return JsonResponse(job_state(job))
|
||||
|
||||
if not ep.audio_url:
|
||||
return JsonResponse({'error': 'no audio'}, status=400)
|
||||
if _too_large(ep.audio_url):
|
||||
return JsonResponse({'error': 'too large'}, status=400)
|
||||
if job is not None:
|
||||
job.delete() # a failed or hollow earlier run: the new one starts at the back of the queue
|
||||
job = TranscriptionJob.objects.create(episode=ep, state=TranscriptionJob.QUEUED, want_article=article)
|
||||
transcribe.kick()
|
||||
return JsonResponse(job_state(job))
|
||||
|
||||
|
||||
def _wishes(user):
|
||||
"""The user's jobs whose transcript is there and whose article is wanted but not written."""
|
||||
return (TranscriptionJob.objects
|
||||
.filter(episode__feed__user=user, state=TranscriptionJob.DONE, want_article=True)
|
||||
.exclude(episode__in=EpisodeArticle.objects.values('episode')))
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['GET'])
|
||||
def transcription_pending(request):
|
||||
"""The open wishes for an article that no device holds right now."""
|
||||
from . import transcribe
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
transcribe.kick()
|
||||
jobs = (_wishes(request.user).exclude(claimed_until__gt=timezone.now())
|
||||
.select_related('episode__feed').order_by('id'))
|
||||
return JsonResponse([{
|
||||
'episode_id': j.episode_id, 'title': j.episode.title, 'podcast': j.episode.feed.title,
|
||||
'duration_seconds': j.episode.duration_seconds,
|
||||
} for j in jobs], safe=False)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST', 'DELETE'])
|
||||
def article_claim(request, pk):
|
||||
"""Reserve the wish for an article for the calling device for CLAIM_MINUTES (POST), or give
|
||||
it up (DELETE). The device names itself with `device` in the JSON body or the
|
||||
X-Diora-Device header. 409 when another device holds it."""
|
||||
from datetime import timedelta
|
||||
if (err := _auth(request)):
|
||||
return err
|
||||
body = _body(request)
|
||||
if body is None:
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
device = body.get('device') or request.META.get('HTTP_X_DIORA_DEVICE') or ''
|
||||
if not isinstance(device, str) or not device.strip():
|
||||
return JsonResponse({'error': 'device missing'}, status=400)
|
||||
device = device.strip()[:60]
|
||||
job = _wishes(request.user).filter(episode_id=pk).first()
|
||||
if job is None:
|
||||
return JsonResponse({'error': 'no open wish'}, status=404)
|
||||
now = timezone.now()
|
||||
if job.claimed_until and job.claimed_until > now and job.claimed_by != device:
|
||||
return JsonResponse({'error': 'claimed', 'claimed_until': job.claimed_until.isoformat()}, status=409)
|
||||
if request.method == 'DELETE':
|
||||
job.claimed_by, job.claimed_until = '', None
|
||||
else:
|
||||
job.claimed_by, job.claimed_until = device, now + timedelta(minutes=CLAIM_MINUTES)
|
||||
job.save(update_fields=['claimed_by', 'claimed_until'])
|
||||
return JsonResponse({'claimed_by': job.claimed_by,
|
||||
'claimed_until': job.claimed_until.isoformat() if job.claimed_until else None})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from django.urls import path
|
||||
from . import article, audiobooks, discover, folders, radio, transcription, views
|
||||
from . import audiobooks, discover, folders, radio, views
|
||||
|
||||
urlpatterns = [
|
||||
path('search/', views.podcast_search, name='podcast_search'),
|
||||
|
|
@ -28,15 +28,9 @@ urlpatterns = [
|
|||
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
|
||||
path('episodes/<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>/article/', article.episode_article, name='podcast_episode_article'),
|
||||
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>/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('queue/', views.queue_get, name='podcast_queue_get'),
|
||||
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
||||
|
|
|
|||
|
|
@ -29,12 +29,11 @@ from . import chapters as chapters_mod
|
|||
from . import silence as silence_mod
|
||||
from . import sponsor as sponsor_mod
|
||||
from . import feedext
|
||||
from . import transcription as transcription_mod
|
||||
from . import transcripts as transcripts_mod
|
||||
from . import youtube as youtube_mod
|
||||
from . import folders as folders_mod
|
||||
from .models import (
|
||||
EpisodeArticle, EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
|
||||
EpisodeProgress, EpisodeTranscript, PlayerDevice, PlayerState, PodcastEpisode, PodcastFeed, PodcastLater,
|
||||
PodcastQueue, PodcastRadioHeard, MAX_SPEED, MIN_SPEED, PodcastSettings, counts_as_played,
|
||||
)
|
||||
from .shownotes import extract_timestamps, sanitize_html
|
||||
|
|
@ -232,7 +231,7 @@ def _later_data(user):
|
|||
.order_by(F('episode__pub_date').desc(nulls_last=True), '-id')
|
||||
.values(
|
||||
'id', 'added_at', 'auto',
|
||||
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
|
||||
'episode__id', 'episode__title', 'episode__audio_url',
|
||||
'episode__duration_seconds', 'episode__artwork_url',
|
||||
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
||||
'episode__pub_date',
|
||||
|
|
@ -384,7 +383,7 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts,
|
|||
return bool(changed)
|
||||
|
||||
|
||||
EXTRA_FIELDS = ['link', 'description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
|
||||
EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at',
|
||||
'title', 'pub_date', 'episode_number']
|
||||
|
||||
|
||||
|
|
@ -643,12 +642,6 @@ def _move_to_permanent_url(feed_obj, 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):
|
||||
"""The episode's file: its audio enclosure, else a video one (the players
|
||||
play only its sound). '' when there is neither."""
|
||||
|
|
@ -720,7 +713,6 @@ def _apply_feed(feed_obj, fetched, full=False):
|
|||
audio_url = _entry_media_url(entry)
|
||||
if not audio_url:
|
||||
continue
|
||||
link = _entry_link(entry)
|
||||
|
||||
guid = entry.get('id') or entry.get('guid') or audio_url
|
||||
guid = str(guid)[:1000]
|
||||
|
|
@ -745,7 +737,7 @@ def _apply_feed(feed_obj, fetched, full=False):
|
|||
|
||||
known = existing.get(guid)
|
||||
if known is not None:
|
||||
renamed = {'title': title, 'episode_number': ep_number, 'link': link}
|
||||
renamed = {'title': title, 'episode_number': ep_number}
|
||||
if pub_date is not None:
|
||||
renamed['pub_date'] = pub_date
|
||||
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts, renamed):
|
||||
|
|
@ -770,7 +762,6 @@ def _apply_feed(feed_obj, fetched, full=False):
|
|||
'title': title,
|
||||
'description': description,
|
||||
'audio_url': audio_url[:1000],
|
||||
'link': link,
|
||||
'duration_seconds': duration_seconds,
|
||||
'pub_date': pub_date,
|
||||
'episode_number': ep_number,
|
||||
|
|
@ -1333,7 +1324,7 @@ def feed_episodes(request, pk):
|
|||
|
||||
episodes = list(
|
||||
feed.episodes.values(
|
||||
'id', 'title', 'description', 'audio_url', 'link', 'duration_seconds',
|
||||
'id', 'title', 'description', 'audio_url', 'duration_seconds',
|
||||
'pub_date', 'artwork_url', 'episode_number', 'season_number',
|
||||
)
|
||||
)
|
||||
|
|
@ -1433,9 +1424,6 @@ def episode_detail(request, pk):
|
|||
chapters = _load_chapters(ep)
|
||||
|
||||
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({
|
||||
'id': ep.id,
|
||||
'title': ep.title,
|
||||
|
|
@ -1443,13 +1431,9 @@ def episode_detail(request, pk):
|
|||
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
|
||||
'chapters': chapters,
|
||||
'transcripts': ep.transcripts,
|
||||
'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,
|
||||
'has_transcript': _has_transcript(ep),
|
||||
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
|
||||
'audio_url': ep.audio_url,
|
||||
'link': ep.link,
|
||||
'artwork_url': ep.artwork_url,
|
||||
'duration_seconds': ep.duration_seconds,
|
||||
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
|
||||
|
|
@ -1477,13 +1461,6 @@ def _has_transcript(ep):
|
|||
return not (cached and cached[0] == sources and cached[1])
|
||||
|
||||
|
||||
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
|
||||
@require_http_methods(['GET'])
|
||||
def episode_silences(request, pk):
|
||||
|
|
@ -1526,28 +1503,20 @@ def episode_sponsor(request, pk):
|
|||
def episode_transcript(request, pk):
|
||||
"""The episode's transcript as {"segments": [{start, end, speaker, text}],
|
||||
"source": {url, type, language}}, fetched and parsed on first use, then
|
||||
kept. When the feed has none we can use, the one Whisper made (a finished
|
||||
TranscriptionJob, docs/transcription.md). 404 {"error": "no transcript" |
|
||||
"unsupported" | "invalid"} when there's none to show; 502
|
||||
{"error": "unavailable"} when it couldn't be loaded right now."""
|
||||
kept. 404 {"error": "no transcript" | "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:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
|
||||
except PodcastEpisode.DoesNotExist:
|
||||
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:
|
||||
return fallback('no transcript')
|
||||
return JsonResponse({'error': 'no transcript'}, status=404)
|
||||
sources = _transcript_sources(ep)
|
||||
if not sources:
|
||||
return fallback('unsupported')
|
||||
return JsonResponse({'error': 'unsupported'}, status=404)
|
||||
|
||||
cached = EpisodeTranscript.objects.filter(episode=ep).first()
|
||||
if cached is not None and cached.sources == sources:
|
||||
|
|
@ -1558,7 +1527,7 @@ def episode_transcript(request, pk):
|
|||
error = ''
|
||||
except chapters_mod.FetchUnavailable as e:
|
||||
log.info('transcript of episode %s not available: %s', ep.id, e)
|
||||
return fallback('unavailable', 502)
|
||||
return JsonResponse({'error': 'unavailable'}, status=502)
|
||||
except transcripts_mod.Unsupported as e:
|
||||
log.info('transcript of episode %s unsupported: %s', ep.id, e)
|
||||
data, error, final = None, 'unsupported', True
|
||||
|
|
@ -1570,7 +1539,7 @@ def episode_transcript(request, pk):
|
|||
episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
|
||||
)
|
||||
if error:
|
||||
return fallback(error)
|
||||
return JsonResponse({'error': error}, status=404)
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
|
|
@ -1676,7 +1645,7 @@ EPISODE_IDS_MAX = 1000
|
|||
EPISODE_LIST_LIMIT = 100
|
||||
EPISODE_LIST_MAX = 500
|
||||
EPISODE_LIST_FIELDS = (
|
||||
'id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||
'id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||
'feed__id', 'feed__title', 'feed__artwork_url',
|
||||
'position_seconds', 'played', 'favorite', 'in_queue', 'in_later', 'last_played_at', 'discovered_at',
|
||||
)
|
||||
|
|
@ -2041,7 +2010,7 @@ def _queue_data(user):
|
|||
.select_related('episode__feed')
|
||||
.values(
|
||||
'id', 'position',
|
||||
'episode__id', 'episode__title', 'episode__audio_url', 'episode__link',
|
||||
'episode__id', 'episode__title', 'episode__audio_url',
|
||||
'episode__duration_seconds', 'episode__artwork_url',
|
||||
'episode__feed__id', 'episode__feed__title', 'episode__feed__artwork_url',
|
||||
'episode__pub_date',
|
||||
|
|
@ -2537,7 +2506,7 @@ def inbox(request):
|
|||
|
||||
|
||||
def _inbox_fields(with_description=True):
|
||||
fields = ['id', 'title', 'audio_url', 'link', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||
fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
||||
'feed__id', 'feed__title', 'feed__artwork_url']
|
||||
if with_description:
|
||||
fields.insert(2, 'description')
|
||||
|
|
|
|||
|
|
@ -734,19 +734,6 @@ html[data-theme="light"] .form-errors { background: #fdecee; border-color: #e7a3
|
|||
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 {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
|
|
@ -1805,25 +1792,14 @@ body.dnd-mode .timer-display {
|
|||
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
|
||||
|
||||
/* PDF text layer — sits flush over the canvas inside pdf-page-inner */
|
||||
.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; line-height:1; -webkit-text-size-adjust:none; text-size-adjust:none; z-index:1; }
|
||||
.pdf-text-layer 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, .pdf-text-layer mark::selection { background:rgba(0,120,255,0.3); }
|
||||
.pdf-text-layer br::selection { background:transparent; }
|
||||
.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-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }
|
||||
.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; }
|
||||
.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); }
|
||||
.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); }
|
||||
.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); }
|
||||
|
||||
/* PDF invert */
|
||||
/* Dark pages: inverted, and the hues turned back, so photos and coloured figures aren't negatives. */
|
||||
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1) 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; }
|
||||
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
|
||||
|
||||
/* ---- Immersive reader mode ---- */
|
||||
.navbar { transition: transform 0.35s ease; }
|
||||
|
|
@ -1848,17 +1824,8 @@ html.nui-tts-on body.reader-immersive .now-playing-bar { transform: none; }
|
|||
html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
|
||||
|
||||
/* PDF paginated */
|
||||
/* 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; }
|
||||
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
|
||||
.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 {
|
||||
|
|
@ -1899,57 +1866,6 @@ html.nui-tts-on body.reader-immersive .reader-overlay { bottom: var(--bar-h); }
|
|||
.footnote-popover.reader-theme-sepia { background:#ede0c4; color:#3b2a1a; border-color:#c8b89a; }
|
||||
.footnote-popover.reader-theme-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 > :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") */
|
||||
.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,
|
||||
// authors, folder names …). Nothing is changed without the user ticking it in review().
|
||||
//
|
||||
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema, effort, maxTokens, onProgress, signal}), review(…)}
|
||||
// window.DioraAI = {available(), hasKey(), settings(), ask({system, prompt, schema}), review(…)}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import Anthropic from '/static/vendor/anthropic-sdk-0.133.0.mjs';
|
||||
|
|
@ -116,17 +116,12 @@ function parseAnswer(text) {
|
|||
}
|
||||
}
|
||||
|
||||
// Words in a text so far (for onProgress).
|
||||
const countWords = t => { const m = String(t || '').match(/\S+/g); return m ? m.length : 0; };
|
||||
|
||||
// One request with a JSON answer of `schema` (structured outputs).
|
||||
// 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}) {
|
||||
async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000}) {
|
||||
const v = await loadVault();
|
||||
const key = keyOf(v);
|
||||
if (!key) throw fail('Kein API-Key hinterlegt.');
|
||||
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal});
|
||||
if (providerOf(v) === 'openrouter') return askOpenRouter(key, {system, prompt, schema, effort, maxTokens});
|
||||
// Anthropic: streamed (long lists), a declined request re-runs on Anthropic's fallback model.
|
||||
const stream = client(key).beta.messages.stream({
|
||||
model: PROVIDERS.anthropic.model,
|
||||
|
|
@ -136,18 +131,8 @@ async function ask({system, prompt, schema, effort = 'low', maxTokens = 64000, o
|
|||
system,
|
||||
messages: [{role: 'user', content: prompt}],
|
||||
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();
|
||||
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 === '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(''));
|
||||
|
|
@ -164,8 +149,8 @@ async function openRouterFetch(key, path, init) {
|
|||
return r.json();
|
||||
}
|
||||
|
||||
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, onProgress, signal}) {
|
||||
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', signal, body: JSON.stringify({
|
||||
async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens}) {
|
||||
const d = await openRouterFetch(key, '/chat/completions', {method: 'POST', body: JSON.stringify({
|
||||
model: PROVIDERS.openrouter.model,
|
||||
max_tokens: maxTokens,
|
||||
reasoning: {effort},
|
||||
|
|
@ -176,9 +161,7 @@ async function askOpenRouter(key, {system, prompt, schema, effort, maxTokens, on
|
|||
if (!choice) throw fail('Die Antwort ließ sich nicht lesen.');
|
||||
if (choice.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.');
|
||||
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);
|
||||
return parseAnswer((choice.message && choice.message.content) || '');
|
||||
}
|
||||
|
||||
// Is this key accepted? (Costs nothing: Anthropic's model info, OpenRouter's key info.)
|
||||
|
|
@ -378,8 +361,6 @@ window.__dioraBack = function () {
|
|||
return back ? back.apply(this, arguments) : false;
|
||||
};
|
||||
|
||||
const modelName = () => PROVIDERS[providerOf(vault)].model;
|
||||
|
||||
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), modelName, settings, withKey, ask, review, progress, showError,
|
||||
window.DioraAI = {available, hasKey: async () => !!(await loadKey()), settings, withKey, ask, review, progress, showError,
|
||||
close, toast, explain};
|
||||
window.dispatchEvent(new Event('diora-ai'));
|
||||
|
|
|
|||
|
|
@ -37,12 +37,9 @@ document.addEventListener('keydown', e => {
|
|||
if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); }
|
||||
}
|
||||
// PDF page by page: arrows, Space, PageUp/PageDown turn (scrolling has its own keys below).
|
||||
if (readerSettings.pdfPaginated && currentPdfDoc && !readerSearchOpen
|
||||
&& !/^(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; }
|
||||
if (readerSettings.pdfPaginated && currentPdfDoc) {
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); }
|
||||
}
|
||||
|
||||
// Vim-style scroll — ignore when typing in an input
|
||||
|
|
|
|||
|
|
@ -41,19 +41,10 @@ async function getOrCreateEncKey() {
|
|||
return _encKey;
|
||||
} catch (e) { /* fall through, generate new */ }
|
||||
}
|
||||
// Just signed in with the password: the login form left the derived key (99-init.js
|
||||
// moves it too, but this may run first).
|
||||
const pending = window.USER_ID ? localStorage.getItem('diora_pending_enc_key') : null;
|
||||
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']);
|
||||
// No key found — generate one and store it
|
||||
_encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||
const raw = await crypto.subtle.exportKey('raw', _encKey);
|
||||
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
|
||||
return _encKey;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,254 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unlocking the books on a device without the key (accounts/keyshare.py)
|
||||
//
|
||||
// A password login derives the book key on the device (login.html). A device signed in
|
||||
// another way (Authelia, accounts/oidc.py) has none: getOrCreateEncKey() then asks here
|
||||
// instead of making a new key. Two ways in:
|
||||
// - an unlocked device of the account hands it over: this device posts an ECDH P-256
|
||||
// public key, the other one shows the same 6-digit code (made from that key), the user
|
||||
// approves there and the book key comes back encrypted for this device only;
|
||||
// - or the diora password, the same derivation as at login.
|
||||
// The server only ever sees public keys and ciphertext.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KEYSHARE_INFO = new TextEncoder().encode('diora keyshare v1');
|
||||
let _unlockPromise = null;
|
||||
let _unlockDismissed = false;
|
||||
|
||||
function dioraLockedError() {
|
||||
const e = new Error('Bücher sind auf diesem Gerät noch nicht entsperrt');
|
||||
e.name = 'DioraLocked';
|
||||
return e;
|
||||
}
|
||||
|
||||
function _csrf() {
|
||||
return document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
|
||||
}
|
||||
|
||||
async function _ksFetch(url, body) {
|
||||
const opts = body === undefined ? {credentials: 'same-origin'} : {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': _csrf()},
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 6-digit code from the asking device's public key, shown on both devices.
|
||||
async function keyshareCode(pubB64) {
|
||||
const h = new Uint8Array(await crypto.subtle.digest('SHA-256', base64ToBytes(pubB64)));
|
||||
const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0;
|
||||
const s = String(n % 1000000).padStart(6, '0');
|
||||
return s.slice(0, 3) + ' ' + s.slice(3);
|
||||
}
|
||||
|
||||
async function _sharedAesKey(privateKey, otherPubB64) {
|
||||
const otherPub = await crypto.subtle.importKey('raw', base64ToBytes(otherPubB64),
|
||||
{name: 'ECDH', namedCurve: 'P-256'}, false, []);
|
||||
const bits = await crypto.subtle.deriveBits({name: 'ECDH', public: otherPub}, privateKey, 256);
|
||||
const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey({name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: KEYSHARE_INFO},
|
||||
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
function _deviceName() {
|
||||
const ua = navigator.userAgent || '';
|
||||
const app = /diora-desktop|Electron/i.test(ua) ? 'diora Desktop'
|
||||
: /; wv\)|diora/i.test(ua) && /Android/i.test(ua) ? 'diora App'
|
||||
: /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge'
|
||||
: /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
|
||||
const os = /Android/i.test(ua) ? 'Android' : /iPhone|iPad/i.test(ua) ? 'iOS'
|
||||
: /Windows/i.test(ua) ? 'Windows' : /Mac OS/i.test(ua) ? 'macOS' : /Linux/i.test(ua) ? 'Linux' : '';
|
||||
return os ? `${app} auf ${os}` : app;
|
||||
}
|
||||
|
||||
// Is this raw key the account's book key? Tried on one book's metadata; without books
|
||||
// there is nothing to compare with, so it is taken.
|
||||
async function _keyFits(raw) {
|
||||
let books = [];
|
||||
try { books = await _ksFetch('/books/'); } catch (e) { return true; }
|
||||
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
|
||||
if (!b) return true;
|
||||
try {
|
||||
const key = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
|
||||
await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function _storeRawKey(raw) {
|
||||
localStorage.setItem(`diora_enc_key_${window.USER_ID || 0}`, bytesToBase64(new Uint8Array(raw)));
|
||||
}
|
||||
|
||||
function _el(tag, attrs, text) {
|
||||
const el = document.createElement(tag);
|
||||
for (const k in (attrs || {})) el.setAttribute(k, attrs[k]);
|
||||
if (text) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function _openDialog(build) {
|
||||
const overlay = _el('div', {class: 'modal-overlay'});
|
||||
const dialog = _el('div', {class: 'modal-dialog', role: 'dialog', 'aria-modal': 'true'});
|
||||
document.body.append(overlay, dialog);
|
||||
const close = () => { overlay.remove(); dialog.remove(); };
|
||||
build(dialog, close);
|
||||
return close;
|
||||
}
|
||||
|
||||
// Ask for the key. Resolves with the raw key (already stored), rejects with DioraLocked.
|
||||
function dioraUnlock(opts) {
|
||||
opts = opts || {};
|
||||
if (_unlockPromise) return _unlockPromise;
|
||||
if (_unlockDismissed && !opts.force) return Promise.reject(dioraLockedError());
|
||||
_unlockPromise = new Promise((resolve, reject) => {
|
||||
let stopped = false;
|
||||
let pair = null, token = null;
|
||||
const finish = (raw) => {
|
||||
stopped = true; close(); _unlockPromise = null; _unlockDismissed = false;
|
||||
_storeRawKey(raw);
|
||||
resolve(raw);
|
||||
document.dispatchEvent(new CustomEvent('diora:unlocked'));
|
||||
};
|
||||
const close = _openDialog((d, closeFn) => {
|
||||
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Bücher entsperren'));
|
||||
d.append(_el('p', {class: 'modal-message'},
|
||||
'Deine Bücher sind mit deinem diora-Passwort verschlüsselt. Öffne diora auf einem Gerät, '
|
||||
+ 'auf dem sie schon offen sind, und bestätige dort diesen Code:'));
|
||||
const code = _el('div', {class: 'unlock-code'}, '··· ···');
|
||||
const status = _el('p', {class: 'unlock-status'}, 'Frage an deine anderen Geräte …');
|
||||
d.append(code, status);
|
||||
d.append(_el('p', {class: 'unlock-or'}, 'Oder gib hier dein diora-Passwort ein:'));
|
||||
const pw = _el('input', {type: 'password', class: 'modal-input', autocomplete: 'current-password',
|
||||
placeholder: 'diora-Passwort'});
|
||||
d.append(pw);
|
||||
const actions = _el('div', {class: 'modal-actions'});
|
||||
const later = _el('button', {class: 'btn', type: 'button'}, 'Später');
|
||||
const ok = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Entsperren');
|
||||
actions.append(later, ok);
|
||||
d.append(actions);
|
||||
|
||||
const tryPassword = async () => {
|
||||
if (!pw.value) { pw.focus(); return; }
|
||||
ok.disabled = true;
|
||||
status.textContent = 'Schlüssel wird abgeleitet …';
|
||||
try {
|
||||
const enc = new TextEncoder();
|
||||
const username = document.querySelector('meta[name="username"]')?.content || '';
|
||||
const mat = await crypto.subtle.importKey('raw', enc.encode(pw.value), 'PBKDF2', false, ['deriveKey']);
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
|
||||
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key));
|
||||
if (!(await _keyFits(raw))) { status.textContent = 'Das Passwort passt nicht zu deinen Büchern.'; ok.disabled = false; return; }
|
||||
finish(raw);
|
||||
} catch (e) {
|
||||
status.textContent = 'Fehler: ' + e.message; ok.disabled = false;
|
||||
}
|
||||
};
|
||||
ok.addEventListener('click', tryPassword);
|
||||
pw.addEventListener('keydown', (e) => { if (e.key === 'Enter') tryPassword(); });
|
||||
later.addEventListener('click', () => {
|
||||
stopped = true; closeFn(); _unlockPromise = null; _unlockDismissed = true;
|
||||
reject(dioraLockedError());
|
||||
});
|
||||
|
||||
// Ask the other devices.
|
||||
(async () => {
|
||||
try {
|
||||
pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
|
||||
const pub = bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)));
|
||||
token = (await _ksFetch('/accounts/keyshare/', {pub, device: _deviceName()})).token;
|
||||
code.textContent = await keyshareCode(pub);
|
||||
status.textContent = 'Warte auf Freigabe …';
|
||||
while (!stopped) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
if (stopped) break;
|
||||
let st;
|
||||
try { st = await _ksFetch(`/accounts/keyshare/${token}/`); } catch (e) { continue; }
|
||||
if (st.state === 'pending') continue;
|
||||
if (st.state === 'declined') { status.textContent = 'Auf dem anderen Gerät abgelehnt.'; break; }
|
||||
if (st.state === 'expired') { status.textContent = 'Die Anfrage ist abgelaufen. Lade die Seite neu, um es noch einmal zu versuchen.'; break; }
|
||||
const aes = await _sharedAesKey(pair.privateKey, st.pub);
|
||||
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: base64ToBytes(st.iv)}, aes, base64ToBytes(st.ct)));
|
||||
if (raw.length !== 32 || !(await _keyFits(raw))) { status.textContent = 'Der übergebene Schlüssel passt nicht.'; break; }
|
||||
finish(raw);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!stopped) status.textContent = 'Übergabe nicht möglich (' + e.message + '). Das Passwort geht immer.';
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
return _unlockPromise;
|
||||
}
|
||||
|
||||
// --- The other side: an unlocked device approves new ones -------------------------------
|
||||
|
||||
const _seenShares = new Set();
|
||||
let _approving = false;
|
||||
|
||||
async function _approveShare(req, rawKey) {
|
||||
const codeText = await keyshareCode(req.pub);
|
||||
return new Promise((resolve) => {
|
||||
_openDialog((d, close) => {
|
||||
d.append(_el('h2', {style: 'margin:0 0 10px;font-size:1.1rem;'}, 'Neues Gerät entsperren?'));
|
||||
d.append(_el('p', {class: 'modal-message'},
|
||||
`„${req.device || 'Ein Gerät'}“ möchte deine Bücher öffnen. Gib nur frei, wenn dort derselbe Code steht:`));
|
||||
d.append(_el('div', {class: 'unlock-code'}, codeText));
|
||||
const status = _el('p', {class: 'unlock-status'});
|
||||
d.append(status);
|
||||
const actions = _el('div', {class: 'modal-actions'});
|
||||
const no = _el('button', {class: 'btn', type: 'button'}, 'Ablehnen');
|
||||
const yes = _el('button', {class: 'btn btn-primary', type: 'button'}, 'Freigeben');
|
||||
actions.append(no, yes);
|
||||
d.append(actions);
|
||||
no.addEventListener('click', async () => {
|
||||
try { await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {decline: true}); } catch (e) {}
|
||||
close(); resolve(false);
|
||||
});
|
||||
yes.addEventListener('click', async () => {
|
||||
yes.disabled = no.disabled = true;
|
||||
try {
|
||||
const pair = await crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveBits']);
|
||||
const aes = await _sharedAesKey(pair.privateKey, req.pub);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aes, rawKey);
|
||||
await _ksFetch(`/accounts/keyshare/${req.token}/answer/`, {
|
||||
pub: bytesToBase64(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))),
|
||||
iv: bytesToBase64(iv), ct: bytesToBase64(new Uint8Array(ct)),
|
||||
});
|
||||
close(); resolve(true);
|
||||
} catch (e) {
|
||||
status.textContent = 'Freigabe fehlgeschlagen: ' + e.message;
|
||||
yes.disabled = no.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkKeyShareRequests() {
|
||||
if (_approving || !window.USER_ID || document.hidden) return;
|
||||
const stored = localStorage.getItem(`diora_enc_key_${window.USER_ID}`);
|
||||
if (!stored) return;
|
||||
let data;
|
||||
try { data = await _ksFetch('/accounts/keyshare/pending/'); } catch (e) { return; }
|
||||
const req = (data.requests || []).find(r => !_seenShares.has(r.token));
|
||||
if (!req) return;
|
||||
_seenShares.add(req.token);
|
||||
_approving = true;
|
||||
try { await _approveShare(req, base64ToBytes(stored)); } finally { _approving = false; }
|
||||
}
|
||||
|
||||
(function () {
|
||||
if (!window.USER_ID || !window.crypto || !crypto.subtle) return;
|
||||
setTimeout(checkKeyShareRequests, 2000);
|
||||
setInterval(checkKeyShareRequests, 15000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) checkKeyShareRequests(); });
|
||||
})();
|
||||
|
|
@ -106,12 +106,10 @@ async function parseEpub(arrayBuffer) {
|
|||
// spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts.
|
||||
async function bookPagesOfFile(buf, type) {
|
||||
if (type === 'pdf') {
|
||||
const pdf = await _pdfLoad(new Uint8Array(buf)).promise;
|
||||
try {
|
||||
return pdf.numPages;
|
||||
} finally {
|
||||
try { pdf.destroy(); } catch (e) {}
|
||||
}
|
||||
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(buf)}).promise;
|
||||
const n = pdf.numPages;
|
||||
pdf.destroy();
|
||||
return n;
|
||||
}
|
||||
const zip = await JSZip.loadAsync(buf);
|
||||
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
||||
|
|
@ -230,41 +228,14 @@ function _injectImageBlobs(html, chapterDir, imageMap) {
|
|||
// Calibre/zeno.org conversions) use neither and just wrap a plain link to
|
||||
// another chapter file's fragment in a <sup> — in either direction
|
||||
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). Recognize all of these
|
||||
// rather than only the single hardcoded class name. Also role="doc-noteref",
|
||||
// more note classes, and a bare note number as the link's text ("3", "[12]", "*").
|
||||
// rather than only the single hardcoded class name.
|
||||
function _looksLikeFootnoteLink(el, href) {
|
||||
if (!href.includes('#')) return false;
|
||||
const cls = (el.getAttribute('class') || '').toLowerCase();
|
||||
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();
|
||||
if (epubType.includes('noteref')) return true;
|
||||
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());
|
||||
return !!(el.closest('sup') || el.querySelector('sup'));
|
||||
}
|
||||
|
||||
// Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML
|
||||
|
|
@ -301,18 +272,15 @@ function sanitizeEpubHtml(html) {
|
|||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
el.setAttribute('target', '_blank');
|
||||
el.setAttribute('rel', 'noopener noreferrer');
|
||||
} else if (!(href.includes('#') && _isNoteBacklink(el)) && _looksLikeFootnoteLink(el, href)) {
|
||||
// data-footnote-ref (the id) marks the link as a reference; the href
|
||||
// as written (data-footnote-href) finds the note in the chapter file it
|
||||
// points into (78-footnotes.js) — ids like "fn1" repeat across files.
|
||||
} else if (_looksLikeFootnoteLink(el, href)) {
|
||||
// The whole book is one concatenated DOM, so a fragment id resolves
|
||||
// via getElementById regardless of which chapter file it originally
|
||||
// pointed into — strip any "../Text/other.html" path prefix and keep
|
||||
// just the id after the last '#'.
|
||||
el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1);
|
||||
el.dataset.footnoteHref = href;
|
||||
el.removeAttribute('href');
|
||||
el.style.cursor = 'pointer';
|
||||
} 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.style.cursor = 'default';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ let _parseCodeSig = null;
|
|||
|
||||
function _parsePreparationSig() {
|
||||
if (_parseCodeSig) return _parseCodeSig;
|
||||
// window.__dioraPrepareSig: what an app layer adds to the preparation — a
|
||||
// change there too.
|
||||
const src = [parseEpub, sanitizeEpubHtml, _looksLikeFootnoteLink, _isNoteBacklink, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
|
||||
// window.__dioraPrepareSig: what an app layer adds to the preparation (the
|
||||
// footnote detection in diora-android's native-ui.js) — a change there too.
|
||||
const src = [parseEpub, sanitizeEpubHtml, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
|
||||
.map(f => String(f)).join('\n') + (window.__dioraPrepareSig || '');
|
||||
let h = 5381;
|
||||
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// Reader settings
|
||||
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
||||
fontFamily: 'serif', noBold: false, fontWeight: null,
|
||||
pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false };
|
||||
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
|
||||
let readerSettingsPanelOpen = false;
|
||||
let currentPdfDoc = null;
|
||||
let currentPdfBuffer = null;
|
||||
|
|
@ -45,6 +45,9 @@ function _readerScrollTick() {
|
|||
_readerScrollLastTs = now;
|
||||
$('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') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
|
||||
|
|
|
|||
|
|
@ -41,13 +41,6 @@ async function loadBookList() {
|
|||
try {
|
||||
key = await getOrCreateEncKey();
|
||||
} 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>`;
|
||||
return;
|
||||
}
|
||||
|
|
@ -133,14 +126,10 @@ function repairBook(bookId) {
|
|||
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
|
||||
try {
|
||||
if (isPdf) {
|
||||
const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
|
||||
try {
|
||||
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
||||
const meta = await pdfDoc.getMetadata();
|
||||
title = meta.info?.Title?.trim() || title;
|
||||
author = meta.info?.Author?.trim() || '';
|
||||
} finally {
|
||||
try { pdfDoc.destroy(); } catch (e) {}
|
||||
}
|
||||
} else {
|
||||
const zip = await JSZip.loadAsync(buf.slice(0));
|
||||
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
||||
|
|
@ -789,14 +778,10 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) {
|
|||
|
||||
if (isPdf) {
|
||||
try {
|
||||
const pdfDoc = await _pdfLoad(new Uint8Array(buf.slice(0))).promise;
|
||||
try {
|
||||
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
||||
const meta = await pdfDoc.getMetadata();
|
||||
title = meta.info?.Title?.trim() || title;
|
||||
author = meta.info?.Author?.trim() || '';
|
||||
} finally {
|
||||
try { pdfDoc.destroy(); } catch (e) {}
|
||||
}
|
||||
} catch (e) { /* use filename as title */ }
|
||||
} else {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -933,7 +933,7 @@ function _libAddMenu() {
|
|||
|
||||
(function () {
|
||||
const style = document.createElement('style');
|
||||
const rules = [
|
||||
style.textContent = [
|
||||
'html.lib-v2 #tab-books .book-drop-zone, html.lib-v2 #tab-books .book-list-filter,',
|
||||
'html.lib-v2 #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; }',
|
||||
|
|
@ -1026,15 +1026,6 @@ function _libAddMenu() {
|
|||
'.cloud-chosen:empty { display: 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; }',
|
||||
];
|
||||
// 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');
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ const _issueNorm = s => String(s || '').toLowerCase().replace(/ß/g, 'ss').norma
|
|||
|
||||
async function _issueIndexPdf(bytes) {
|
||||
if (typeof pdfjsLib === 'undefined') return null;
|
||||
const pdf = await _pdfLoad(new Uint8Array(bytes)).promise;
|
||||
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
|
||||
try {
|
||||
return await _issuePdfArticles(pdf);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,569 +1,49 @@
|
|||
'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) {
|
||||
depth = depth || 0;
|
||||
const parts = await Promise.all(items.map(async item => {
|
||||
const result = [];
|
||||
for (const item of items) {
|
||||
let href = '';
|
||||
if (item.dest) {
|
||||
const n = await _pdfDestPage(pdf, item.dest);
|
||||
if (n) href = `#pdf-page-${n}`;
|
||||
try {
|
||||
const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest;
|
||||
if (dest) {
|
||||
const pageIndex = await pdf.getPageIndex(dest[0]);
|
||||
href = `#pdf-page-${pageIndex + 1}`;
|
||||
}
|
||||
const kids = item.items && item.items.length ? await _parsePdfOutline(pdf, item.items, depth + 1) : [];
|
||||
return [{label: item.title || '(untitled)', href, depth}].concat(kids);
|
||||
}));
|
||||
return [].concat.apply([], parts);
|
||||
} catch (e) {}
|
||||
}
|
||||
result.push({label: item.title || '(untitled)', href, depth});
|
||||
if (item.items && item.items.length) {
|
||||
result.push(...await _parsePdfOutline(pdf, item.items, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// The document being loaded (pdf.js loading task), so opening can be cancelled.
|
||||
let _pdfLoadingTask = null;
|
||||
// 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.
|
||||
// The pages' drawing of the last renderPdf: an observer and its queue (stopped on the next one).
|
||||
let _pdfLazy = null;
|
||||
|
||||
// The open document's text, page by page (pdf.js text content): for the text layer, the
|
||||
// search and the crop to the text box. Kept for the document, not for one layout of it.
|
||||
let _pdfTexts = null;
|
||||
|
||||
function _pdfPageText(pdf, num) {
|
||||
if (!_pdfTexts || _pdfTexts.pdf !== pdf) _pdfTexts = {pdf, pages: new Map()};
|
||||
let p = _pdfTexts.pages.get(num);
|
||||
if (!p) {
|
||||
p = pdf.getPage(num).then(page => page.getTextContent()).catch(() => ({items: [], styles: {}}));
|
||||
_pdfTexts.pages.set(num, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's text as one string, lower case, for searching — with where each item (each span of
|
||||
* the text layer, in order) starts in it. Lines ending inside it get a space.
|
||||
*/
|
||||
function _pdfPageString(tc) {
|
||||
if (tc._diora) return tc._diora;
|
||||
let str = '';
|
||||
const starts = [];
|
||||
for (const item of tc.items) {
|
||||
starts.push(str.length);
|
||||
str += item.str || '';
|
||||
if (item.hasEOL) str += ' ';
|
||||
}
|
||||
tc._diora = {str: str.toLowerCase(), starts};
|
||||
return tc._diora;
|
||||
}
|
||||
|
||||
// Title, table of contents and page labels: asked for once per document, alongside the
|
||||
// pages (opening doesn't wait for them; a layout anew reuses them).
|
||||
let _pdfExtras = null;
|
||||
|
||||
function _pdfDocExtras(pdf) {
|
||||
if (_pdfExtras && _pdfExtras.pdf === pdf) return _pdfExtras;
|
||||
const meta = pdf.getMetadata().then(m => ({
|
||||
title: (m && m.info && m.info.Title || '').trim(),
|
||||
author: (m && m.info && m.info.Author || '').trim(),
|
||||
}), () => ({title: '', author: ''}));
|
||||
const toc = pdf.getOutline().then(o => (o && o.length ? _parsePdfOutline(pdf, o) : []), () => []);
|
||||
const labels = pdf.getPageLabels().then(l => {
|
||||
// Labels that only say 1, 2, 3 … are no labels.
|
||||
if (!l || !l.length || l.every((s, i) => !s || s === String(i + 1))) return null;
|
||||
return l.map((s, i) => s || String(i + 1));
|
||||
}, () => null);
|
||||
_pdfExtras = {pdf, meta, toc, labels};
|
||||
return _pdfExtras;
|
||||
}
|
||||
|
||||
// The page labels of the open document (front matter "iv", appendix "A-3" …), null without.
|
||||
let _pdfLabels = null;
|
||||
|
||||
/** Page [n] (from 1) as the book numbers it. */
|
||||
function pdfPageLabel(n) {
|
||||
return (_pdfLabels && _pdfLabels[n - 1]) || String(n);
|
||||
}
|
||||
|
||||
/** The page meant by [value] typed in: a label of the book first, else a page number. 0: none. */
|
||||
function _pdfPageFromInput(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return 0;
|
||||
if (_pdfLabels) {
|
||||
let i = _pdfLabels.indexOf(v);
|
||||
if (i < 0) i = _pdfLabels.findIndex(l => l.toLowerCase() === v.toLowerCase());
|
||||
if (i >= 0) return i + 1;
|
||||
}
|
||||
return parseInt(v, 10) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages in the reader's colours (Leserfarben): {background, foreground} as #rrggbb, the
|
||||
* reader's (Dunkel, Sepia …). null: as printed (also when the reader is black on white).
|
||||
*/
|
||||
function _pdfPageColors() {
|
||||
if (!readerSettings.pdfPageColors || readerSettings.pdfInverted) return null;
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return null;
|
||||
let bg = null;
|
||||
for (let el = contentEl; el && !bg; el = el.parentElement) bg = _pdfCssHex(getComputedStyle(el).backgroundColor);
|
||||
const fg = _pdfCssHex(getComputedStyle(contentEl).color);
|
||||
// On white the page is as printed anyway (Hell).
|
||||
if (!bg || !fg || bg === '#ffffff') return null;
|
||||
return {background: bg, foreground: fg};
|
||||
}
|
||||
|
||||
/** "rgb(…)" / "rgba(…)" as #rrggbb; null for transparent. */
|
||||
function _pdfCssHex(css) {
|
||||
const m = /rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?/.exec(css || '');
|
||||
if (!m || (m[4] !== undefined && parseFloat(m[4]) === 0)) return null;
|
||||
return '#' + [m[1], m[2], m[3]].map(x => (+x).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Whether the pages are drawn for Leserfarben (with their pictures laid over them, below):
|
||||
// switching that draws them anew; another theme only changes the filter's colours.
|
||||
function _pdfColorsKey() {
|
||||
return _pdfPageColors() ? 'reader' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Leserfarben: the page is drawn as printed and recoloured by an SVG filter on its canvas —
|
||||
* its brightness from the reader's text colour (black) to its background (white). (pdf.js's
|
||||
* own pageColors recolours the pictures too, and lets nothing be left out.) Kept up to date
|
||||
* with the colours; true when it applies.
|
||||
*/
|
||||
function _pdfColorFilter() {
|
||||
const c = _pdfPageColors();
|
||||
if (!c) return false;
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
let svg = document.getElementById('pdf-reader-colors-svg');
|
||||
if (!svg) {
|
||||
svg = document.createElementNS(ns, 'svg');
|
||||
svg.id = 'pdf-reader-colors-svg';
|
||||
svg.setAttribute('width', '0');
|
||||
svg.setAttribute('height', '0');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.style.position = 'absolute';
|
||||
svg.innerHTML = '<filter id="pdf-reader-colors" color-interpolation-filters="sRGB">' +
|
||||
'<feColorMatrix type="matrix" values="0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 ' +
|
||||
'0.2126 0.7152 0.0722 0 0 0 0 0 1 0"/>' +
|
||||
'<feComponentTransfer><feFuncR type="table"/><feFuncG type="table"/><feFuncB type="table"/>' +
|
||||
'</feComponentTransfer></filter>';
|
||||
document.body.appendChild(svg);
|
||||
}
|
||||
const ch = (hex, i) => (parseInt(hex.substr(1 + i * 2, 2), 16) / 255).toFixed(3);
|
||||
['R', 'G', 'B'].forEach((name, i) => {
|
||||
svg.querySelector('feFunc' + name).setAttribute('tableValues', `${ch(c.foreground, i)} ${ch(c.background, i)}`);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where page [page]'s pictures are, in CSS pixels of [viewport]: from its drawing operations
|
||||
* (the unit square of each image under the transform at that point). Image masks are left
|
||||
* out (they're drawn in a colour, like text).
|
||||
*/
|
||||
async function _pdfImageRects(page, viewport) {
|
||||
const OPS = pdfjsLib.OPS;
|
||||
let list;
|
||||
try {
|
||||
list = await page.getOperatorList({annotationMode: pdfjsLib.AnnotationMode.ENABLE_FORMS});
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
const mul = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
|
||||
m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
|
||||
m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
|
||||
let ctm = viewport.transform.slice();
|
||||
const stack = [];
|
||||
const rects = [];
|
||||
for (let i = 0; i < list.fnArray.length; i++) {
|
||||
const fn = list.fnArray[i], args = list.argsArray[i];
|
||||
if (fn === OPS.save) stack.push(ctm);
|
||||
else if (fn === OPS.restore) ctm = stack.pop() || ctm;
|
||||
else if (fn === OPS.transform) ctm = mul(ctm, args);
|
||||
else if (fn === OPS.paintFormXObjectBegin) {
|
||||
stack.push(ctm);
|
||||
if (Array.isArray(args[0]) && args[0].length === 6) ctm = mul(ctm, args[0]);
|
||||
} else if (fn === OPS.paintFormXObjectEnd) ctm = stack.pop() || ctm;
|
||||
else if (fn === OPS.paintImageXObject || fn === OPS.paintInlineImageXObject) {
|
||||
const xs = [ctm[4], ctm[0] + ctm[4], ctm[2] + ctm[4], ctm[0] + ctm[2] + ctm[4]];
|
||||
const ys = [ctm[5], ctm[1] + ctm[5], ctm[3] + ctm[5], ctm[1] + ctm[3] + ctm[5]];
|
||||
const x = Math.max(0, Math.min.apply(null, xs)), y = Math.max(0, Math.min.apply(null, ys));
|
||||
const w = Math.min(viewport.width, Math.max.apply(null, xs)) - x;
|
||||
const h = Math.min(viewport.height, Math.max.apply(null, ys)) - y;
|
||||
if (w > 2 && h > 2) rects.push({x, y, w, h});
|
||||
}
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
// A picture covering this much of the page is the page itself (a scan): recoloured with it.
|
||||
const PDF_SCAN_SHARE = 0.5;
|
||||
|
||||
/** Leserfarben: page [e]'s pictures laid over it as printed (copied from its canvas). */
|
||||
async function _pdfImageLayer(e, page, viewport, ratio, token) {
|
||||
const rects = await _pdfImageRects(page, viewport);
|
||||
if (e.token !== token) return;
|
||||
const area = viewport.width * viewport.height;
|
||||
const shown = rects.filter(r => r.w * r.h < area * PDF_SCAN_SHARE);
|
||||
if (!shown.length) return;
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'pdf-image-layer';
|
||||
for (const r of shown) {
|
||||
const c = document.createElement('canvas');
|
||||
const sx = Math.floor(r.x * ratio), sy = Math.floor(r.y * ratio);
|
||||
const sw = Math.min(e.canvas.width - sx, Math.ceil(r.w * ratio)), sh = Math.min(e.canvas.height - sy, Math.ceil(r.h * ratio));
|
||||
if (sw <= 0 || sh <= 0) continue;
|
||||
c.width = sw;
|
||||
c.height = sh;
|
||||
c.getContext('2d').drawImage(e.canvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
c.style.left = sx / ratio + 'px';
|
||||
c.style.top = sy / ratio + 'px';
|
||||
c.style.width = sw / ratio + 'px';
|
||||
c.style.height = sh / ratio + 'px';
|
||||
layer.appendChild(c);
|
||||
}
|
||||
e.inner.insertBefore(layer, e.canvas.nextSibling);
|
||||
}
|
||||
|
||||
// Internal links followed, the pages they were followed from (the back button).
|
||||
let _pdfBackStack = [];
|
||||
|
||||
/** The document is gone (another one, or the reader closed): what belonged to it goes too. */
|
||||
function _pdfForgetDoc() {
|
||||
_pdfTexts = null;
|
||||
_pdfExtras = null;
|
||||
_pdfLabels = null;
|
||||
_pdfBackStack = [];
|
||||
_pdfShowBack();
|
||||
if (typeof _pdfSearchStop === 'function') _pdfSearchStop();
|
||||
}
|
||||
|
||||
async function _pdfFollowDest(dest) {
|
||||
const pdf = currentPdfDoc;
|
||||
if (!pdf) return;
|
||||
const n = await _pdfDestPage(pdf, dest);
|
||||
if (!n || pdf !== currentPdfDoc) return;
|
||||
const contentEl = $('reader-content');
|
||||
const from = readerSettings.pdfPaginated || !contentEl ? pdfCurrentPage : _pdfTopPage(contentEl);
|
||||
if (n === from) return;
|
||||
_pdfBackStack.push(from);
|
||||
pdfGoToPage(n);
|
||||
_pdfShowBack();
|
||||
}
|
||||
|
||||
/** The button back to where the last link was followed from (gone when there's none). */
|
||||
function _pdfShowBack() {
|
||||
let btn = document.getElementById('pdf-back-btn');
|
||||
const from = _pdfBackStack[_pdfBackStack.length - 1];
|
||||
if (!from) {
|
||||
if (btn) btn.remove();
|
||||
return;
|
||||
}
|
||||
if (!btn) {
|
||||
const overlay = $('reader-overlay');
|
||||
if (!overlay) return;
|
||||
btn = document.createElement('button');
|
||||
btn.id = 'pdf-back-btn';
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm pdf-back-btn';
|
||||
btn.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
const page = _pdfBackStack.pop();
|
||||
if (page) pdfGoToPage(page);
|
||||
_pdfShowBack();
|
||||
});
|
||||
overlay.appendChild(btn);
|
||||
}
|
||||
btn.textContent = `← Zurück zu S. ${pdfPageLabel(from)}`;
|
||||
}
|
||||
|
||||
/** A page's text layer and links taken off (another scale, or the page let go). */
|
||||
function _pdfDropLayers(e) {
|
||||
if (e.textTask) { try { e.textTask.cancel(); } catch (err) {} }
|
||||
e.textTask = null;
|
||||
e.textDivs = null;
|
||||
e.textInfo = null;
|
||||
e.marked = null;
|
||||
e.inner.querySelectorAll('.pdf-text-layer, .pdf-link-layer, .pdf-image-layer').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Over a drawn page: its text (transparent, to select and copy, and where the search marks
|
||||
* go) and its links. After the pixels, so a turn isn't held up by them.
|
||||
*/
|
||||
async function _pdfLayers(e, page, viewport, token) {
|
||||
const pdf = currentPdfDoc;
|
||||
if (!pdf) return;
|
||||
let annots = [];
|
||||
try { annots = await page.getAnnotations({intent: 'display'}); } catch (err) {}
|
||||
if (e.token !== token) return;
|
||||
_pdfLinks(e, viewport, annots);
|
||||
if (typeof pdfjsLib.renderTextLayer !== 'function') return;
|
||||
const text = await _pdfPageText(pdf, e.num);
|
||||
if (e.token !== token || !text.items.length) return;
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'pdf-text-layer';
|
||||
layer.style.setProperty('--scale-factor', viewport.scale);
|
||||
e.inner.appendChild(layer);
|
||||
const divs = [];
|
||||
const task = pdfjsLib.renderTextLayer({textContentSource: text, container: layer, viewport, textDivs: divs});
|
||||
e.textTask = task;
|
||||
try {
|
||||
await task.promise;
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
if (e.token !== token || e.textTask !== task) return;
|
||||
e.textTask = null;
|
||||
e.textDivs = divs;
|
||||
e.textInfo = {items: text.items, string: _pdfPageString(text)};
|
||||
if (typeof _pdfSearchMarkPage === 'function') _pdfSearchMarkPage(e);
|
||||
}
|
||||
|
||||
// Links: ones into the document go there (and back, _pdfShowBack); web addresses open outside.
|
||||
function _pdfLinks(e, viewport, annots) {
|
||||
const links = annots.filter(a => a.subtype === 'Link' && (a.url || a.dest));
|
||||
if (!links.length) return;
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'pdf-link-layer';
|
||||
for (const a of links) {
|
||||
const r = viewport.convertToViewportRectangle(a.rect);
|
||||
const link = document.createElement('a');
|
||||
link.className = 'pdf-link';
|
||||
link.style.left = Math.min(r[0], r[2]) + 'px';
|
||||
link.style.top = Math.min(r[1], r[3]) + 'px';
|
||||
link.style.width = Math.abs(r[2] - r[0]) + 'px';
|
||||
link.style.height = Math.abs(r[3] - r[1]) + 'px';
|
||||
if (a.url) {
|
||||
link.href = a.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.title = a.url;
|
||||
} else {
|
||||
const dest = a.dest;
|
||||
link.href = '#';
|
||||
link.addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
_pdfFollowDest(dest);
|
||||
});
|
||||
}
|
||||
layer.appendChild(link);
|
||||
}
|
||||
e.inner.appendChild(layer);
|
||||
}
|
||||
|
||||
function _pdfStopLazy() {
|
||||
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;
|
||||
if (_pdfLazy) {
|
||||
_pdfLazy.stopped = true;
|
||||
if (_pdfLazy.observer) _pdfLazy.observer.disconnect();
|
||||
_pdfLazy = null;
|
||||
}
|
||||
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
|
||||
// pages near the view are drawn; ones far away are let go again. A book of 1000 scanned pages
|
||||
// opens as fast as one of ten, and doesn't hold 1000 canvases. Page by page (pdfPaginated),
|
||||
// 66-pdf-paginated.js shows and draws the pages itself.
|
||||
// opens as fast as one of ten, and doesn't hold 1000 canvases.
|
||||
async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||
const myGen = ++_pdfRenderGen;
|
||||
_pdfStopLazy();
|
||||
let pdf = currentPdfDoc;
|
||||
if (!pdf) {
|
||||
_pdfForgetDoc();
|
||||
_pdfLoadingTask = _pdfLoad(new Uint8Array(arrayBuffer.slice(0)));
|
||||
_pdfLoadingTask = pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))});
|
||||
try {
|
||||
pdf = await _pdfLoadingTask.promise;
|
||||
} finally {
|
||||
|
|
@ -572,15 +52,20 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
|||
}
|
||||
if (_pdfRenderGen !== myGen) return null;
|
||||
currentPdfDoc = pdf;
|
||||
// [pivotPage] may be given as what of the document it is (the place saved, a fraction).
|
||||
if (typeof pivotPage === 'function') pivotPage = pivotPage(pdf.numPages);
|
||||
|
||||
const extras = _pdfDocExtras(pdf);
|
||||
extras.labels.then(labels => {
|
||||
if (pdf !== currentPdfDoc) return;
|
||||
_pdfLabels = labels;
|
||||
if (labels) _pdfSetCurrentPage(pdfCurrentPage);
|
||||
});
|
||||
let pdfTitle = '', pdfAuthor = '';
|
||||
try {
|
||||
const meta = await pdf.getMetadata();
|
||||
pdfTitle = meta.info?.Title?.trim() || '';
|
||||
pdfAuthor = meta.info?.Author?.trim() || '';
|
||||
} catch (e) {}
|
||||
|
||||
let toc = [];
|
||||
try {
|
||||
const outline = await pdf.getOutline();
|
||||
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
|
||||
} catch (e) {}
|
||||
if (_pdfRenderGen !== myGen) return null;
|
||||
|
||||
contentEl.innerHTML = '';
|
||||
|
||||
|
|
@ -592,6 +77,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
|||
? contentEl.clientWidth - 32
|
||||
: Math.min(contentEl.clientWidth - 32, 900);
|
||||
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const scaleFor = vp => (scaleOverride != null ? scaleOverride
|
||||
: Math.max(0.5, pageWidth / vp.width) * (readerSettings.pdfZoom / 100));
|
||||
|
||||
|
|
@ -616,7 +102,7 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
|||
canvas.style.height = firstVp.height + 'px';
|
||||
inner.appendChild(canvas);
|
||||
wrapper.appendChild(inner);
|
||||
if (!readerSettings.pdfSpread || readerSettings.pdfPaginated) {
|
||||
if (!readerSettings.pdfSpread) {
|
||||
pdfVp.appendChild(wrapper);
|
||||
} else if (pageNum === 1) {
|
||||
wrapper.classList.add('pdf-spread-cover');
|
||||
|
|
@ -631,93 +117,78 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
|||
if (spreadContainer) spreadContainer.appendChild(wrapper);
|
||||
else pdfVp.appendChild(wrapper);
|
||||
}
|
||||
entries.push({num: pageNum, wrapper, inner, canvas, drawn: 0, near: false, task: null, token: 0});
|
||||
entries.push({num: pageNum, wrapper, canvas, drawn: false, near: false, task: null});
|
||||
}
|
||||
|
||||
pdfTotalPages = pdf.numPages;
|
||||
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
|
||||
|
||||
const view = {pdf, contentEl, entries, gen: myGen, stopped: false, observer: null, resize: null,
|
||||
resizeTimer: 0, queue: new Set(), working: false, scaleFor, showToken: 0,
|
||||
colorsKey: _pdfColorsKey()};
|
||||
_pdfView = _pdfLazy = view;
|
||||
const gone = () => view.stopped || _pdfRenderGen !== myGen;
|
||||
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};
|
||||
_pdfLazy = lazy;
|
||||
const gone = () => lazy.stopped || _pdfRenderGen !== myGen;
|
||||
|
||||
async function draw(e) {
|
||||
const page = await pdf.getPage(e.num);
|
||||
if (gone() || !e.near || readerSettings.pdfPaginated) return;
|
||||
await _pdfDraw(e, page, scaleFor(page.getViewport({scale: 1})));
|
||||
if (gone() || !e.near) return;
|
||||
const viewport = page.getViewport({scale: scaleFor(page.getViewport({scale: 1}))});
|
||||
e.canvas.style.width = viewport.width + 'px';
|
||||
e.canvas.style.height = viewport.height + 'px';
|
||||
e.canvas.width = Math.round(viewport.width * dpr);
|
||||
e.canvas.height = Math.round(viewport.height * dpr);
|
||||
const ctx = e.canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
e.task = page.render({canvasContext: ctx, viewport});
|
||||
try {
|
||||
await e.task.promise;
|
||||
e.drawn = true;
|
||||
} catch (err) { /* cancelled: let go below */ }
|
||||
e.task = null;
|
||||
}
|
||||
|
||||
function letGo(e) {
|
||||
if (e.task) { try { e.task.cancel(); } catch (err) {} }
|
||||
if (!e.drawn) return;
|
||||
e.drawn = false;
|
||||
e.canvas.width = 0; // frees the pixels; the CSS size keeps the page's place
|
||||
e.canvas.height = 0;
|
||||
}
|
||||
|
||||
// One page at a time, the nearest to the current one first.
|
||||
async function work() {
|
||||
if (view.working) return;
|
||||
view.working = true;
|
||||
if (lazy.working) return;
|
||||
lazy.working = true;
|
||||
try {
|
||||
while (!gone() && view.queue.size && !readerSettings.pdfPaginated) {
|
||||
const cur = _pdfTopPage(contentEl);
|
||||
const next = Array.from(view.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
|
||||
view.queue.delete(next);
|
||||
if (next.near && (!next.drawn || next.stale)) await draw(next);
|
||||
while (!gone() && lazy.queue.size) {
|
||||
const cur = typeof _currentScrollPage === 'function' && !readerSettings.pdfPaginated
|
||||
? _currentScrollPage(contentEl) : (pdfCurrentPage || pivot);
|
||||
const next = Array.from(lazy.queue).sort((a, b) => Math.abs(a.num - cur) - Math.abs(b.num - cur))[0];
|
||||
lazy.queue.delete(next);
|
||||
if (next.near && !next.drawn) await draw(next);
|
||||
}
|
||||
} finally {
|
||||
view.working = false;
|
||||
lazy.working = false;
|
||||
}
|
||||
}
|
||||
|
||||
view.work = work;
|
||||
|
||||
// Turning the device, a narrower window, the bars coming or going: laid out again at the same page.
|
||||
let lastW = contentEl.clientWidth, lastH = contentEl.clientHeight;
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
view.resize = new ResizeObserver(() => {
|
||||
const w = contentEl.clientWidth, h = contentEl.clientHeight;
|
||||
if (!w || !h || (w === lastW && h === lastH)) return;
|
||||
const widthChanged = Math.abs(w - lastW) > 2;
|
||||
lastW = w;
|
||||
lastH = h;
|
||||
clearTimeout(view.resizeTimer);
|
||||
view.resizeTimer = setTimeout(() => {
|
||||
if (gone()) return;
|
||||
if (readerSettings.pdfPaginated) _pdfShowPage(pdfCurrentPage);
|
||||
else if (widthChanged) reRenderPdf(_pdfTopPage(contentEl));
|
||||
}, readerSettings.pdfPaginated ? 150 : 300);
|
||||
});
|
||||
view.resize.observe(contentEl);
|
||||
}
|
||||
|
||||
if (readerSettings.pdfPaginated) {
|
||||
// Page by page: the pivot page cropped to its text, at once (66-pdf-paginated.js).
|
||||
pdfCurrentPage = pivot;
|
||||
await _pdfShowPage(pivot);
|
||||
if (gone()) return null;
|
||||
return _pdfResult(pdf, extras);
|
||||
}
|
||||
|
||||
// Opening at the saved page is no "scrolled far" (44-books.js).
|
||||
if (pivot > 1) {
|
||||
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
|
||||
entries[pivot - 1].wrapper.scrollIntoView({block: 'start'});
|
||||
}
|
||||
pdfCurrentPage = pivot;
|
||||
|
||||
const byEl = new Map(entries.map(e => [e.wrapper, e]));
|
||||
if (typeof IntersectionObserver === 'function') {
|
||||
view.observer = new IntersectionObserver(items => {
|
||||
if (readerSettings.pdfPaginated) return;
|
||||
lazy.observer = new IntersectionObserver(items => {
|
||||
for (const it of items) {
|
||||
const e = byEl.get(it.target);
|
||||
if (!e) continue;
|
||||
e.near = it.isIntersecting;
|
||||
if (e.near) { if (!e.drawn) view.queue.add(e); }
|
||||
else { view.queue.delete(e); if (e.drawn || e.task) _pdfLetGo(e, pdf); }
|
||||
if (e.near) { if (!e.drawn) lazy.queue.add(e); }
|
||||
else { lazy.queue.delete(e); letGo(e); }
|
||||
}
|
||||
work();
|
||||
}, {root: contentEl, rootMargin: '150% 0px'});
|
||||
entries.forEach(e => view.observer.observe(e.wrapper));
|
||||
entries.forEach(e => lazy.observer.observe(e.wrapper));
|
||||
} else {
|
||||
// Very old WebViews: every page, from the pivot on (as before).
|
||||
entries.forEach(e => { e.near = true; view.queue.add(e); });
|
||||
entries.forEach(e => { e.near = true; lazy.queue.add(e); });
|
||||
work();
|
||||
}
|
||||
|
||||
|
|
@ -726,12 +197,5 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
|||
pe.near = true;
|
||||
if (!pe.drawn) await draw(pe);
|
||||
if (gone()) return null;
|
||||
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};
|
||||
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,65 +29,6 @@ function _immHandleTap(e) {
|
|||
}
|
||||
}
|
||||
|
||||
// A double click on the text (with a mouse): full screen, the bars out of the
|
||||
// way; again (or Esc): back as it was. Fingers keep their double tap; beside
|
||||
// the text a click turns the page (63-pages.js), so only on the text itself.
|
||||
let _readerFsHidBars = false;
|
||||
|
||||
// What openBook hangs on the content and the page number for this book, taken off again in
|
||||
// closeReader — else every book opened adds its own, and a PDF's handlers run under the next
|
||||
// EPUB. (Not addEventListener's signal: the tolino's Chrome 61 ignores it.)
|
||||
let _readerListeners = [];
|
||||
|
||||
function _readerListen(el, type, fn, opts) {
|
||||
if (!el) return;
|
||||
el.addEventListener(type, fn, opts);
|
||||
_readerListeners.push([el, type, fn, opts]);
|
||||
}
|
||||
|
||||
function _readerUnlistenAll() {
|
||||
for (const [el, type, fn, opts] of _readerListeners) el.removeEventListener(type, fn, opts);
|
||||
_readerListeners = [];
|
||||
}
|
||||
|
||||
function _readerDblClick(e) {
|
||||
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
||||
if (!e.target.closest('[data-epub-src], .pdf-page-wrapper')) return;
|
||||
if (e.target.closest('button, a, input, select, textarea, label, [data-footnote-ref], mark.epub-highlight')) return;
|
||||
// The double click also picked a word: that's not a marking.
|
||||
const sel = window.getSelection();
|
||||
if (sel) sel.removeAllRanges();
|
||||
if (typeof dismissHighlightPopover === 'function') dismissHighlightPopover();
|
||||
if (document.fullscreenElement) {
|
||||
if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const el = document.documentElement;
|
||||
if (!el.requestFullscreen) return;
|
||||
el.requestFullscreen().then(() => {
|
||||
if (_immBarsVisible) {
|
||||
_immBarsVisible = false;
|
||||
document.body.classList.add('reader-immersive');
|
||||
_readerFsHidBars = true;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (document.fullscreenElement || !_readerFsHidBars) return;
|
||||
_readerFsHidBars = false;
|
||||
const overlay = $('reader-overlay');
|
||||
if (overlay && overlay.style.display !== 'none' && !_immBarsVisible) {
|
||||
_immBarsVisible = true;
|
||||
document.body.classList.remove('reader-immersive');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const row = $('reader-body-row');
|
||||
if (row) row.addEventListener('dblclick', _readerDblClick);
|
||||
});
|
||||
|
||||
function enterReaderImmersiveMode() {
|
||||
_immBarsVisible = true;
|
||||
document.body.classList.remove('reader-immersive');
|
||||
|
|
@ -123,10 +64,8 @@ function cancelBookOpening() {
|
|||
if (overlay) overlay.style.display = 'none';
|
||||
const contentEl = $('reader-content');
|
||||
if (contentEl) contentEl.innerHTML = '';
|
||||
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
|
||||
currentPdfDoc = null;
|
||||
currentPdfBuffer = null;
|
||||
_readerUnlistenAll();
|
||||
_readPos = null;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -147,36 +86,6 @@ async function openBook(bookId) {
|
|||
}
|
||||
}
|
||||
|
||||
async /**
|
||||
* Book [bookId]'s entry with its place: the server's (offline: the one kept here), or an own
|
||||
* newer one the server doesn't have yet. null when there's none.
|
||||
*/
|
||||
async function _bookProgress(bookId) {
|
||||
let bookData = null;
|
||||
try {
|
||||
const progressRes = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(progressRes);
|
||||
const allBooks = await progressRes.json();
|
||||
bookData = allBooks.find(b => b.id === bookId) || null;
|
||||
} catch {
|
||||
// offline — use cached meta
|
||||
return _bookProgressCached(bookId);
|
||||
}
|
||||
// The newer of the server's place and an own one it doesn't have yet.
|
||||
return bookData ? _withPendingProgress(bookData) : null;
|
||||
}
|
||||
|
||||
/** The same from what's kept on this device only (no waiting for the network). */
|
||||
async function _bookProgressCached(bookId) {
|
||||
try {
|
||||
const cached = await _loadCachedBookMeta();
|
||||
const bookData = cached.find(b => b.id === bookId) || null;
|
||||
return bookData ? _withPendingProgress(bookData) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _openBookOnce(bookId) {
|
||||
// Nothing is saved for this book until its place is restored.
|
||||
_readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false};
|
||||
|
|
@ -198,9 +107,6 @@ async function _openBookOnce(bookId) {
|
|||
loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening());
|
||||
const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400);
|
||||
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 {
|
||||
loadReaderSettings(bookId);
|
||||
|
|
@ -245,28 +151,14 @@ async function _openBookOnce(bookId) {
|
|||
const isPdfBook = cachedMeta.type === 'pdf';
|
||||
|
||||
if (isPdfBook) {
|
||||
// A document still open (another book) goes; renderPdf loads this one.
|
||||
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));
|
||||
currentPdfDoc = null; // reset so renderPdf creates fresh doc
|
||||
const result = await renderPdf(plain, contentEl);
|
||||
if (left() || !result) return;
|
||||
title = result.title || title;
|
||||
author = result.author || author;
|
||||
toc = result.toc;
|
||||
numPages = result.numPages;
|
||||
// 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;
|
||||
currentPdfBuffer = plain;
|
||||
} else {
|
||||
currentPdfBuffer = null;
|
||||
// Prepared before (same book file, same preparation code): only the images to unpack.
|
||||
|
|
@ -327,14 +219,18 @@ async function _openBookOnce(bookId) {
|
|||
// Apply reader settings (theme, font size, etc.)
|
||||
applyReaderSettings(isPdfBook);
|
||||
|
||||
// The notes margin stays closed when a book opens (also with notes in it): it's one
|
||||
// tap away (✎), and opens by itself when a note is written.
|
||||
// If this book already has notes/highlights, surface them right away
|
||||
// instead of making the reader hide them behind a manual toggle.
|
||||
// Not on phones: there the margin covers the text; it stays one tap away (✎).
|
||||
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) {
|
||||
toggleAnnotationsMargin();
|
||||
}
|
||||
|
||||
// Touch: swipe (paginated) + pinch-to-zoom
|
||||
_readerListen(contentEl, 'touchstart', _pdfTouchStart, {passive: true});
|
||||
_readerListen(contentEl, 'touchmove', _pdfTouchMove, {passive: false});
|
||||
_readerListen(contentEl, 'touchend', _pdfTouchEnd, {passive: true});
|
||||
_readerListen(contentEl, 'touchcancel', _pdfTouchEnd, {passive: true});
|
||||
contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
|
||||
contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
|
||||
contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
|
||||
contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true});
|
||||
|
||||
// Set up progress input
|
||||
const progressInput = $('reader-progress-input');
|
||||
|
|
@ -351,13 +247,16 @@ async function _openBookOnce(bookId) {
|
|||
if (progressSuffix) progressSuffix.textContent = '%';
|
||||
}
|
||||
|
||||
_readerListen(progressInput, 'change', function () {
|
||||
progressInput.addEventListener('change', function () {
|
||||
_suppressScrollJumpDetect();
|
||||
if (isPdf) {
|
||||
// The book's page number ("iv", "12") or the file's.
|
||||
const page = Math.min(numPages, Math.max(1, _pdfPageFromInput(this.value) || pdfCurrentPage || 1));
|
||||
this.value = pdfPageLabel(page);
|
||||
pdfGoToPage(page);
|
||||
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
|
||||
this.value = page;
|
||||
const target = contentEl.querySelector(`#pdf-page-${page}`);
|
||||
if (target) {
|
||||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||||
contentEl.scrollBy({top: top - 8, behavior: 'smooth'});
|
||||
}
|
||||
} else {
|
||||
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
||||
this.value = _formatBookPercent(pct / 100);
|
||||
|
|
@ -365,7 +264,7 @@ async function _openBookOnce(bookId) {
|
|||
}
|
||||
});
|
||||
|
||||
_readerListen(progressInput, 'click', function () { this.select(); });
|
||||
progressInput.addEventListener('click', function () { this.select(); });
|
||||
}
|
||||
|
||||
// Restore scroll position — must happen BEFORE auto-save timer is started
|
||||
|
|
@ -373,7 +272,19 @@ async function _openBookOnce(bookId) {
|
|||
let openedProgress = null;
|
||||
try {
|
||||
let fraction = 0, anchor = '';
|
||||
const bookData = await progressP;
|
||||
let bookData = null;
|
||||
try {
|
||||
const progressRes = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(progressRes);
|
||||
const allBooks = await progressRes.json();
|
||||
bookData = allBooks.find(b => b.id === bookId) || null;
|
||||
} catch {
|
||||
// offline — use cached meta
|
||||
const cached = await _loadCachedBookMeta();
|
||||
bookData = cached.find(b => b.id === bookId) || null;
|
||||
}
|
||||
// The newer of the server's place and an own one it doesn't have yet.
|
||||
if (bookData) bookData = _withPendingProgress(bookData);
|
||||
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||||
anchor = bookData ? (bookData.position_anchor || '') : '';
|
||||
openedProgress = bookData;
|
||||
|
|
@ -387,7 +298,6 @@ async function _openBookOnce(bookId) {
|
|||
await new Promise(r => requestAnimationFrame(r));
|
||||
if (fraction > 0)
|
||||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||||
_pdfSetCurrentPage(_pdfTopPage(contentEl));
|
||||
} else {
|
||||
// EPUB — wait for images so scrollHeight is final
|
||||
const imgs = Array.from(contentEl.querySelectorAll('img'));
|
||||
|
|
@ -420,24 +330,29 @@ async function _openBookOnce(bookId) {
|
|||
// first — that could otherwise be a later, unrelated layout correction.
|
||||
_scrollRestTop = contentEl.scrollTop;
|
||||
|
||||
// PDF scrolling: the page shown at the top is the current one (header, bookmarks, the
|
||||
// app's footer). EPUB: shown from the position anchor, see the anchor tracking below.
|
||||
// Update progress input on scroll
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
if (!progressInput) return;
|
||||
if (isPdf) {
|
||||
let pageRaf = 0;
|
||||
_readerListen(contentEl, 'scroll', () => {
|
||||
if (pageRaf || readerSettings.pdfPaginated) return;
|
||||
pageRaf = requestAnimationFrame(() => {
|
||||
pageRaf = 0;
|
||||
if (currentPdfDoc) _pdfSetCurrentPage(_pdfTopPage(contentEl));
|
||||
});
|
||||
}, {passive: true});
|
||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||||
const cTop = contentEl.getBoundingClientRect().top;
|
||||
let currentPage = 1;
|
||||
for (const w of wrappers) {
|
||||
if (w.getBoundingClientRect().bottom > cTop + 20) {
|
||||
currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
progressInput.value = currentPage;
|
||||
}
|
||||
// EPUB: shown from the position anchor, see the anchor tracking below.
|
||||
});
|
||||
|
||||
// Auto-save progress every 10s and on scroll (debounced 2s)
|
||||
// Started AFTER restore so an early visibilitychange can't overwrite with position 0
|
||||
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
|
||||
let _scrollDebounce = null;
|
||||
_readerListen(contentEl, 'scroll', () => {
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
clearTimeout(_scrollDebounce);
|
||||
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
|
||||
}, {passive: true});
|
||||
|
|
@ -447,7 +362,7 @@ async function _openBookOnce(bookId) {
|
|||
// scrolling) would otherwise restore to a stale, pre-scroll anchor.
|
||||
if (!isPdf) {
|
||||
let _anchorTrackDebounce = null;
|
||||
_readerListen(contentEl, 'scroll', () => {
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
clearTimeout(_anchorTrackDebounce);
|
||||
_anchorTrackDebounce = setTimeout(() => {
|
||||
_currentPositionAnchor = getPositionAnchor(contentEl);
|
||||
|
|
@ -476,7 +391,7 @@ async function _openBookOnce(bookId) {
|
|||
// fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above.
|
||||
_scrollRestTop = null;
|
||||
let _scrollJumpRaf = null;
|
||||
_readerListen(contentEl, 'scroll', () => {
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
if (_scrollJumpRaf) return;
|
||||
_scrollJumpRaf = requestAnimationFrame(() => {
|
||||
_scrollJumpRaf = null;
|
||||
|
|
@ -634,8 +549,6 @@ function closeReader() {
|
|||
// Still opening: just leave it (nothing to save yet).
|
||||
if (cancelBookOpening()) return;
|
||||
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();
|
||||
stopReadAloud();
|
||||
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
||||
|
|
@ -685,12 +598,12 @@ function closeReader() {
|
|||
|
||||
// Remove touch handlers and free image blob URLs
|
||||
const contentEl = $('reader-content');
|
||||
_readerUnlistenAll();
|
||||
if (contentEl) {
|
||||
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
||||
contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
|
||||
contentEl.classList.remove('pinch-active', 'pdf-paginated');
|
||||
contentEl.style.overflow = '';
|
||||
contentEl.removeEventListener('touchstart', _pdfTouchStart);
|
||||
contentEl.removeEventListener('touchmove', _pdfTouchMove);
|
||||
contentEl.removeEventListener('touchend', _pdfTouchEnd);
|
||||
contentEl.removeEventListener('touchcancel', _pdfTouchEnd);
|
||||
contentEl.classList.remove('pinch-active');
|
||||
contentEl.innerHTML = '';
|
||||
}
|
||||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||||
|
|
@ -703,7 +616,6 @@ function closeReader() {
|
|||
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
|
||||
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
|
||||
currentPdfDoc = null;
|
||||
if (typeof _pdfForgetDoc === 'function') _pdfForgetDoc();
|
||||
currentPdfBuffer = null;
|
||||
currentBookmarks = [];
|
||||
bookmarksDirty = false;
|
||||
|
|
@ -755,9 +667,13 @@ function jumpToTocEntry(href) {
|
|||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
// PDF page jump (scrolling or page by page)
|
||||
// PDF page jump
|
||||
if (href.startsWith('#pdf-page-')) {
|
||||
pdfGoToPage(parseInt(href.slice('#pdf-page-'.length), 10) || 1);
|
||||
const target = contentEl.querySelector(href);
|
||||
if (target) {
|
||||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,14 +15,10 @@ function _readerTypeDefaults() {
|
|||
? _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) {
|
||||
_readerSettingsKey = bookId;
|
||||
// Reset to defaults, then apply per-book overrides
|
||||
Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false,
|
||||
pdfZoom: 100, pdfInverted: false, pdfPageColors: false, pdfPaginated: false, pdfSpread: false },
|
||||
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false },
|
||||
_readerTypeDefaults());
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
|
||||
|
|
@ -44,9 +40,8 @@ function loadReaderSettings(bookId) {
|
|||
}
|
||||
|
||||
function saveReaderSettings() {
|
||||
const key = currentBookId || _readerSettingsKey;
|
||||
if (key) {
|
||||
localStorage.setItem(`diora_reader_settings_${key}`, JSON.stringify({ ...readerSettings, v: 3 }));
|
||||
if (currentBookId) {
|
||||
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,12 +89,6 @@ function applyReaderSettings(isPdf) {
|
|||
// PDF invert
|
||||
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('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
|
||||
const marginBtn = $('reader-margin-btn');
|
||||
|
|
@ -124,21 +113,6 @@ function _readerWeight() {
|
|||
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() {
|
||||
const overlay = $('reader-overlay');
|
||||
const contentEl = $('reader-content');
|
||||
|
|
@ -174,20 +148,12 @@ function toggleSettingsPanel() {
|
|||
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
|
||||
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
|
||||
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>
|
||||
${_readerModeButtons()}
|
||||
`;
|
||||
} else {
|
||||
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>
|
||||
<button class="btn btn-sm ${readerSettings.pdfPaginated ? '' : 'active'}" data-rs-pdfmode="scroll">Scrollen</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>
|
||||
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invertieren</button>
|
||||
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Doppelseite</button>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -301,78 +267,21 @@ function toggleSettingsPanel() {
|
|||
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 {
|
||||
const zoomRange = panel.querySelector('#rs-zoom');
|
||||
|
||||
// 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);
|
||||
});
|
||||
zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10)));
|
||||
panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
|
||||
applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10)));
|
||||
panel.querySelector('#rs-zoom-plus').addEventListener('click', () =>
|
||||
applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10)));
|
||||
|
||||
// The pages' colours: as printed, in the reader's colours, or inverted.
|
||||
panel.querySelectorAll('[data-rs-pdfcolors]').forEach(btn => {
|
||||
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));
|
||||
panel.querySelector('#rs-invert').addEventListener('click', function () {
|
||||
readerSettings.pdfInverted = !readerSettings.pdfInverted;
|
||||
this.classList.toggle('active', readerSettings.pdfInverted);
|
||||
applyReaderSettings(true);
|
||||
saveReaderSettings();
|
||||
});
|
||||
});
|
||||
|
||||
// Scrolling or page by page (phones and e-readers start with pages).
|
||||
panel.querySelectorAll('[data-rs-pdfmode]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const pages = btn.dataset.rsPdfmode === 'pages';
|
||||
if (pages === !!readerSettings.pdfPaginated || !currentPdfDoc) return;
|
||||
const contentEl = $('reader-content');
|
||||
const page = pages ? _pdfTopPage(contentEl) : pdfCurrentPage;
|
||||
readerSettings.pdfPaginated = pages;
|
||||
saveReaderSettings();
|
||||
panel.querySelectorAll('[data-rs-pdfmode]').forEach(b => b.classList.toggle('active', b === btn));
|
||||
const spread = panel.querySelector('#rs-spread');
|
||||
if (spread) spread.style.display = pages ? 'none' : '';
|
||||
if (pages) {
|
||||
pdfCurrentPage = page;
|
||||
// Spreads are a scrolling thing: one page at a time here.
|
||||
if (readerSettings.pdfSpread) reRenderPdf(page).then(() => enterPdfPaginatedMode());
|
||||
else enterPdfPaginatedMode();
|
||||
} else {
|
||||
exitPdfPaginatedMode(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelector('#rs-spread').addEventListener('click', function () {
|
||||
readerSettings.pdfSpread = !readerSettings.pdfSpread;
|
||||
|
|
@ -384,42 +293,37 @@ function toggleSettingsPanel() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
async function applyPdfZoom(newZoom) {
|
||||
readerSettings.pdfZoom = newZoom;
|
||||
const zoomRange = document.getElementById('rs-zoom');
|
||||
const zoomVal = document.getElementById('rs-zoom-val');
|
||||
if (zoomRange) zoomRange.value = newZoom;
|
||||
if (zoomVal) zoomVal.textContent = newZoom + '%';
|
||||
saveReaderSettings();
|
||||
if (!currentPdfDoc) return;
|
||||
if (readerSettings.pdfPaginated) {
|
||||
await _pdfShowPage(pdfCurrentPage);
|
||||
return;
|
||||
pdfSmartZoomPage(pdfCurrentPage);
|
||||
} else {
|
||||
await reRenderPdf();
|
||||
}
|
||||
if (newZoom === old) return;
|
||||
if (!anchor) {
|
||||
const r = $('reader-content').getBoundingClientRect();
|
||||
anchor = _pdfPointAt(r.left + r.width / 2, r.top + r.height / 2);
|
||||
}
|
||||
if (!_pdfRezoomScroll(newZoom / old, anchor)) await reRenderPdf();
|
||||
}
|
||||
|
||||
// The page at the top of the view (scrolling) — 58-pdf.js keeps the pages in order.
|
||||
function _currentScrollPage(contentEl) {
|
||||
return _pdfTopPage(contentEl);
|
||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||||
const viewTop = contentEl.scrollTop;
|
||||
for (const w of wrappers) {
|
||||
if (w.offsetTop + w.offsetHeight > viewTop) {
|
||||
const n = parseInt(w.id.replace('pdf-page-', ''), 10);
|
||||
if (n) return n;
|
||||
}
|
||||
}
|
||||
return pdfCurrentPage || 1;
|
||||
}
|
||||
|
||||
/** Lay the open PDF out again (zoom, spreads, another width), at [pivot] or the page shown now. */
|
||||
async function reRenderPdf(pivot) {
|
||||
if (!currentPdfDoc) return;
|
||||
async function reRenderPdf() {
|
||||
if (!currentPdfBuffer) return;
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
if (!pivot) pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _pdfTopPage(contentEl);
|
||||
await renderPdf(null, contentEl, undefined, pivot);
|
||||
const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl);
|
||||
await renderPdf(currentPdfBuffer, contentEl, undefined, pivot);
|
||||
if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Articles in the book reader (DioraArticle) — blog posts and news (feeds.js)
|
||||
// read like books: the same overlay, typography and colours (⚙, kept for all
|
||||
// articles together), pages or scrolling (63-pages.js) with the notes at the
|
||||
// page's foot, search, read-aloud, full screen. Bookmarks, markings and the
|
||||
// notes margin stay with books (an article has nowhere to keep them).
|
||||
//
|
||||
// The article is one "chapter" in #reader-content; currentBookId stays null,
|
||||
// so nothing of a book's position or annotations is saved. Where it was read
|
||||
// to goes back to its owner (onProgress), as do clicks on its own buttons.
|
||||
// ---------------------------------------------------------------------------
|
||||
(function () {
|
||||
let session = null; // {title, html, startFraction, onProgress, onClick, onClose, actions}
|
||||
let progressTimer = 0;
|
||||
|
||||
const overlay = () => document.getElementById('reader-overlay');
|
||||
const content = () => document.getElementById('reader-content');
|
||||
|
||||
// Same-page links: the notes of a blog post ("¹" → "#fn1") become references
|
||||
// like in books; other jumps inside the post must not change the app's
|
||||
// address (#books, #feeds …): kept as internal links (the apps follow them).
|
||||
function markLinks(root) {
|
||||
root.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||
const href = a.getAttribute('href');
|
||||
a.removeAttribute('href');
|
||||
if (href.length < 2) return;
|
||||
// "#t=754": a second in a podcast's article, never a note.
|
||||
if (!/^#t=\d+$/.test(href) && !_isNoteBacklink(a) && _looksLikeFootnoteLink(a, href)) {
|
||||
a.dataset.footnoteRef = href.slice(1);
|
||||
a.dataset.footnoteHref = href;
|
||||
a.style.cursor = 'pointer';
|
||||
} else {
|
||||
a.dataset.internalHref = href;
|
||||
}
|
||||
});
|
||||
// Everything else opens outside.
|
||||
root.querySelectorAll('a[href]').forEach(a => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
});
|
||||
}
|
||||
|
||||
// How far it is read: down to the bottom of what is on screen (0-1).
|
||||
function fraction() {
|
||||
const c = content();
|
||||
const ch = c && c.querySelector(':scope > [data-epub-src]');
|
||||
if (!ch || !ch.offsetHeight) return 0;
|
||||
return Math.max(0, Math.min(1, (c.scrollTop + c.clientHeight - ch.offsetTop) / ch.offsetHeight));
|
||||
}
|
||||
|
||||
function report() {
|
||||
if (session && session.onProgress) session.onProgress(fraction());
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
clearTimeout(progressTimer);
|
||||
progressTimer = setTimeout(report, 1200);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
if (session && session.onClick) session.onClick(e);
|
||||
}
|
||||
|
||||
// The article's own buttons (star, project, original): in the browser's
|
||||
// header; the apps put them into their reader menu (native-ui.js asks actions()).
|
||||
function renderActions() {
|
||||
const bar = document.querySelector('#reader-overlay .reader-header-actions');
|
||||
if (!bar) return;
|
||||
let box = bar.querySelector('.reader-article-actions');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.className = 'reader-article-actions';
|
||||
bar.insertBefore(box, bar.firstChild);
|
||||
}
|
||||
box.textContent = '';
|
||||
actions().forEach(a => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn-icon' + (a.active ? ' active' : '');
|
||||
b.title = a.label;
|
||||
b.textContent = a.icon;
|
||||
b.addEventListener('click', e => { e.stopPropagation(); a.action(); });
|
||||
box.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function actions() {
|
||||
return session && session.actions ? session.actions().filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function open(o) {
|
||||
const ov = overlay();
|
||||
const c = content();
|
||||
if (!ov || !c) return;
|
||||
if (session) {
|
||||
report();
|
||||
stopReadAloud(); // the next article: the voice was reading the last one
|
||||
}
|
||||
else if (ov.style.display !== 'none') closeReader(); // a book was open
|
||||
session = o;
|
||||
loadReaderSettings('article');
|
||||
_charIndex = null;
|
||||
_readPos = null;
|
||||
currentBookId = null;
|
||||
ov.classList.add('reader-article-mode');
|
||||
$('reader-title').textContent = o.title || '';
|
||||
c.textContent = '';
|
||||
const ch = document.createElement('div');
|
||||
ch.setAttribute('data-epub-src', 'article');
|
||||
ch.id = 'epub-chapter-0';
|
||||
ch.className = 'fd-artview';
|
||||
ch.innerHTML = o.html;
|
||||
markLinks(ch);
|
||||
c.appendChild(ch);
|
||||
ov.style.display = '';
|
||||
applyReaderSettings(false);
|
||||
enterReaderImmersiveMode();
|
||||
renderActions();
|
||||
c.scrollTop = 0;
|
||||
if (window.DioraPages) DioraPages.opened();
|
||||
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
|
||||
const f = o.startFraction || 0;
|
||||
if (f > 0.02 && f < 0.9) {
|
||||
// Back where reading stopped (on any device).
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
if (session !== o) return;
|
||||
_suppressScrollJumpDetect();
|
||||
c.scrollTop = Math.max(0, f * ch.offsetHeight + ch.offsetTop - c.clientHeight);
|
||||
} else {
|
||||
setTimeout(() => { if (session === o) report(); }, 1500); // short enough to fit: read at once
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces what is shown (the overview of a story arrived) without moving.
|
||||
function update(html) {
|
||||
const ch = content() && content().querySelector(':scope > [data-epub-src="article"]');
|
||||
if (!ch || !session) return;
|
||||
session.html = html;
|
||||
ch.innerHTML = html;
|
||||
markLinks(ch);
|
||||
}
|
||||
|
||||
// closeReader (62-reader.js) calls this before it empties the reader.
|
||||
window._articleReaderClosed = function () {
|
||||
if (!session) return;
|
||||
clearTimeout(progressTimer);
|
||||
report();
|
||||
const s = session;
|
||||
session = null;
|
||||
overlay().classList.remove('reader-article-mode');
|
||||
const box = document.querySelector('#reader-overlay .reader-article-actions');
|
||||
if (box) box.remove();
|
||||
if (s.onClose) setTimeout(s.onClose, 0);
|
||||
};
|
||||
|
||||
window.DioraArticle = {
|
||||
open,
|
||||
update,
|
||||
isOpen: () => !!session,
|
||||
close: () => { if (session) closeReader(); },
|
||||
actions,
|
||||
refreshActions: renderActions,
|
||||
fraction,
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const c = content();
|
||||
if (!c) return;
|
||||
c.addEventListener('scroll', () => { if (session) onScroll(); }, {passive: true});
|
||||
c.addEventListener('click', e => { if (session) onClick(e); });
|
||||
});
|
||||
})();
|
||||
|
|
@ -1,386 +1,175 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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).
|
||||
// PDF Paginated Mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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() {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
contentEl.classList.add('pdf-paginated');
|
||||
contentEl.style.overflow = 'hidden';
|
||||
const pdfVp = document.getElementById('pdf-viewport');
|
||||
if (pdfVp) { pdfVp.style.transform = ''; pdfVp.style.transformOrigin = ''; }
|
||||
// From scrolling: the drawing near the view stops, the pages are this mode's now.
|
||||
const v = _pdfView;
|
||||
if (v && v.observer) { v.observer.disconnect(); v.observer = null; v.queue.clear(); }
|
||||
if (pdfVp) pdfVp.style.zoom = 1;
|
||||
|
||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||||
wrappers.forEach((w, i) => {
|
||||
w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none';
|
||||
});
|
||||
|
||||
pdfSmartZoomPage(pdfCurrentPage);
|
||||
|
||||
// Tap left/right to navigate
|
||||
contentEl.addEventListener('click', _pdfPaginatedClick);
|
||||
contentEl.addEventListener('wheel', _pdfPaginatedWheel, {passive: false});
|
||||
_pdfShowPage(pdfCurrentPage);
|
||||
}
|
||||
|
||||
/** Back to scrolling, at [page] (the one shown now). */
|
||||
function exitPdfPaginatedMode(page) {
|
||||
function exitPdfPaginatedMode() {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
contentEl.classList.remove('pdf-paginated');
|
||||
contentEl.style.overflow = '';
|
||||
contentEl.removeEventListener('click', _pdfPaginatedClick);
|
||||
contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
|
||||
reRenderPdf(page || pdfCurrentPage);
|
||||
reRenderPdf();
|
||||
}
|
||||
|
||||
function _pdfPaginatedClick(e) {
|
||||
if (e.target.closest('a, button, input')) return;
|
||||
// The page was just moved with a finger: no tap.
|
||||
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);
|
||||
const w = e.currentTarget.clientWidth;
|
||||
if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1);
|
||||
else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1);
|
||||
}
|
||||
|
||||
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) {
|
||||
const v = _pdfView;
|
||||
if (!currentPdfDoc || !v) return;
|
||||
const contentEl = $('reader-content');
|
||||
if (e.touches.length === 2) {
|
||||
const a = e.touches[0], b = e.touches[1];
|
||||
const x = (a.clientX + b.clientX) / 2, y = (a.clientY + b.clientY) / 2;
|
||||
const r = contentEl.getBoundingClientRect();
|
||||
const g = {kind: 'pinch', dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1,
|
||||
zoom: readerSettings.pdfZoom, f: 1, cx: x - r.left, cy: y - r.top};
|
||||
if (readerSettings.pdfPaginated) {
|
||||
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};
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
_pinchStartDist = Math.hypot(dx, dy);
|
||||
_pinchStartZoom = readerSettings.pdfZoom;
|
||||
_isPinching = true;
|
||||
const ce = $('reader-content');
|
||||
if (ce) ce.classList.add('pinch-active');
|
||||
} else {
|
||||
const vp = document.getElementById('pdf-viewport');
|
||||
if (!vp) return;
|
||||
const vr = vp.getBoundingClientRect();
|
||||
g.vp = vp;
|
||||
g.ox = x - vr.left;
|
||||
g.oy = y - vr.top;
|
||||
g.anchor = _pdfPointAt(x, y);
|
||||
}
|
||||
_pdfGesture = g;
|
||||
contentEl.classList.add('pinch-active');
|
||||
return;
|
||||
}
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
_touchStartX = t.clientX;
|
||||
_touchStartY = t.clientY;
|
||||
_pdfGesture = null;
|
||||
const pe = readerSettings.pdfPaginated && v.entries[pdfCurrentPage - 1];
|
||||
if (pe && pe.place && (pe.place.minX < pe.place.maxX || pe.place.minY < pe.place.maxY)) {
|
||||
_pdfGesture = {kind: 'pan', entry: pe, x: t.clientX, y: t.clientY, ox: pe.pos.x, oy: pe.pos.y,
|
||||
dx: 0, dy: 0, moved: false};
|
||||
_touchStartX = e.touches[0].clientX;
|
||||
_isPinching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _pdfTouchMove(e) {
|
||||
const g = _pdfGesture;
|
||||
if (!g) return;
|
||||
if (g.kind === 'pinch') {
|
||||
if (e.touches.length !== 2) return;
|
||||
if (e.touches.length !== 2 || !_isPinching) return;
|
||||
e.preventDefault();
|
||||
const a = e.touches[0], b = e.touches[1];
|
||||
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||
const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, g.zoom * dist / g.dist));
|
||||
g.f = zoom / g.zoom;
|
||||
if (g.entry) {
|
||||
// The page grows around the point between the fingers (its transform's origin is 0 0).
|
||||
const x = g.cx - g.f * (g.cx - g.pos.x), y = g.cy - g.f * (g.cy - g.pos.y);
|
||||
g.entry.inner.style.transform = `translate(${x}px, ${y}px) scale(${g.f})`;
|
||||
} else {
|
||||
g.vp.style.transformOrigin = `${g.ox}px ${g.oy}px`;
|
||||
g.vp.style.transform = `scale(${g.f})`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (g.kind === 'pan' && e.touches.length === 1) {
|
||||
const t = e.touches[0];
|
||||
g.dx = t.clientX - g.x;
|
||||
g.dy = t.clientY - g.y;
|
||||
if (!g.moved && Math.hypot(g.dx, g.dy) < 8) return;
|
||||
g.moved = true;
|
||||
e.preventDefault();
|
||||
_pdfPlace(g.entry, g.ox + g.dx, g.oy + g.dy);
|
||||
}
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (_pinchStartDist === 0) return;
|
||||
const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist)));
|
||||
const vp = document.getElementById('pdf-viewport');
|
||||
if (vp) vp.style.zoom = liveZoom / 100;
|
||||
}
|
||||
|
||||
function _pdfTouchEnd(e) {
|
||||
const g = _pdfGesture;
|
||||
if (g && g.kind === 'pinch') {
|
||||
// The other finger may stay down a moment: it's no swipe.
|
||||
_pdfGesture = {kind: 'after'};
|
||||
const contentEl = $('reader-content');
|
||||
if (contentEl) contentEl.classList.remove('pinch-active');
|
||||
const zoom = Math.max(PDF_ZOOM_MIN, Math.min(PDF_ZOOM_MAX, Math.round(g.zoom * g.f / 10) * 10));
|
||||
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);
|
||||
if (_isPinching) {
|
||||
_isPinching = false;
|
||||
const ce = $('reader-content');
|
||||
if (ce) ce.classList.remove('pinch-active');
|
||||
const vp = document.getElementById('pdf-viewport');
|
||||
const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom;
|
||||
const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10));
|
||||
applyPdfZoom(snapped);
|
||||
return;
|
||||
}
|
||||
if (g && g.kind === 'after') {
|
||||
if (!e.touches || !e.touches.length) _pdfGesture = null;
|
||||
return;
|
||||
}
|
||||
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));
|
||||
if (!readerSettings.pdfPaginated) return;
|
||||
const delta = e.changedTouches[0].clientX - _touchStartX;
|
||||
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
|
||||
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 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) {
|
||||
if (!currentPdfDoc) return;
|
||||
n = Math.max(1, Math.min(pdfTotalPages, n | 0));
|
||||
n = Math.max(1, Math.min(pdfTotalPages, n));
|
||||
if (n === pdfCurrentPage) return;
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
if (!readerSettings.pdfPaginated) {
|
||||
const target = contentEl.querySelector(`#pdf-page-${n}`);
|
||||
if (target) {
|
||||
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
|
||||
contentEl.scrollTop += target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top - 8;
|
||||
}
|
||||
_pdfSetCurrentPage(n);
|
||||
return;
|
||||
}
|
||||
if (n === pdfCurrentPage && _pdfView && _pdfView.shown === n) return;
|
||||
_pdfShowPage(n);
|
||||
_pdfTurned();
|
||||
|
||||
const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
||||
if (oldWrapper) oldWrapper.style.display = 'none';
|
||||
|
||||
pdfCurrentPage = n;
|
||||
const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
|
||||
if (newWrapper) newWrapper.style.display = '';
|
||||
|
||||
pdfSmartZoomPage(pdfCurrentPage);
|
||||
|
||||
const progressInput = $('reader-progress-input');
|
||||
if (progressInput) progressInput.value = pdfCurrentPage;
|
||||
}
|
||||
|
||||
// A turn is reading: the place is saved soon (not only by the 10 s timer), and e-readers
|
||||
// get their refresh (native-eink.js listens).
|
||||
function _pdfTurned() {
|
||||
if (typeof _noteReaderInteraction === 'function') _noteReaderInteraction();
|
||||
clearTimeout(_pdfTurnSaveTimer);
|
||||
_pdfTurnSaveTimer = setTimeout(() => { if (currentPdfDoc) saveReaderProgress(); }, 1500);
|
||||
try { window.dispatchEvent(new CustomEvent('diora-pdf-turn', {detail: {page: pdfCurrentPage}})); } catch (e) {}
|
||||
async function pdfSmartZoomPage(pageNum) {
|
||||
if (!currentPdfDoc) return;
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`);
|
||||
if (!wrapper) return;
|
||||
const canvas = wrapper.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const page = await currentPdfDoc.getPage(pageNum);
|
||||
const naturalVp = page.getViewport({scale: 1});
|
||||
const pageW = naturalVp.width;
|
||||
const pageH = naturalVp.height;
|
||||
|
||||
let bbox = _pdfPageTextBoxCache[pageNum];
|
||||
if (!bbox) {
|
||||
bbox = await _computePdfTextBox(page, pageW, pageH);
|
||||
_pdfPageTextBoxCache[pageNum] = bbox;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/** 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;
|
||||
async function _computePdfTextBox(page, pageW, pageH) {
|
||||
// Tier 1: text-based
|
||||
try {
|
||||
const tc = currentPdfDoc ? await _pdfPageText(currentPdfDoc, page.pageNumber) : await page.getTextContent();
|
||||
const tc = await page.getTextContent();
|
||||
if (tc.items && tc.items.length) {
|
||||
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
||||
for (const item of tc.items) {
|
||||
if (!item.transform || !item.str || !item.str.trim()) continue;
|
||||
if (!item.transform) continue;
|
||||
const tx = item.transform[4], ty = item.transform[5];
|
||||
const iw = item.width || 0, ih = item.height || Math.abs(item.transform[3]) || 0;
|
||||
for (const [px, py] of [[tx, ty], [tx + iw, ty + ih]]) {
|
||||
const [vx, vy] = natural.convertToViewportPoint(px, py);
|
||||
if (vx < x1) x1 = vx;
|
||||
if (vx > x2) x2 = vx;
|
||||
if (vy < y1) y1 = vy;
|
||||
if (vy > y2) y2 = vy;
|
||||
}
|
||||
const iw = item.width || 0, ih = item.height || 0;
|
||||
if (tx < x1) x1 = tx;
|
||||
if (ty < y1) y1 = ty;
|
||||
if (tx + iw > x2) x2 = tx + iw;
|
||||
if (ty + ih > y2) y2 = ty + ih;
|
||||
}
|
||||
const area = (x2 - x1) * (y2 - y1);
|
||||
if (isFinite(x1) && area > pageW * pageH * 0.25) {
|
||||
return {x1: Math.max(0, x1), y1: Math.max(0, y1), x2: Math.min(pageW, x2), y2: Math.min(pageH, y2)};
|
||||
return {x1, y1, x2, y2};
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
|
@ -390,18 +179,17 @@ async function _computePdfTextBox(page, natural) {
|
|||
const lowScale = 0.3;
|
||||
const vp = page.getViewport({scale: lowScale});
|
||||
const offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = Math.ceil(vp.width);
|
||||
offCanvas.height = Math.ceil(vp.height);
|
||||
offCanvas.width = vp.width;
|
||||
offCanvas.height = vp.height;
|
||||
const ctx = offCanvas.getContext('2d');
|
||||
await page.render({canvasContext: ctx, viewport: vp}).promise;
|
||||
const {data, width, height} = ctx.getImageData(0, 0, offCanvas.width, offCanvas.height);
|
||||
offCanvas.width = offCanvas.height = 0;
|
||||
const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height);
|
||||
|
||||
let rMin = height, rMax = 0, cMin = width, cMax = 0;
|
||||
for (let r = 0; r < height; r++) {
|
||||
for (let c = 0; c < width; c++) {
|
||||
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 > rMax) rMax = r;
|
||||
if (c < cMin) cMin = c;
|
||||
|
|
@ -410,7 +198,12 @@ async function _computePdfTextBox(page, natural) {
|
|||
}
|
||||
}
|
||||
if (rMin < rMax && cMin < cMax) {
|
||||
return {x1: cMin / lowScale, y1: rMin / lowScale, x2: cMax / lowScale, y2: rMax / lowScale};
|
||||
return {
|
||||
x1: cMin / lowScale,
|
||||
y1: (height - rMax) / lowScale,
|
||||
x2: cMax / lowScale,
|
||||
y2: (height - rMin) / lowScale,
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ function addBookmark() {
|
|||
let label, anchor, scrollFraction;
|
||||
|
||||
if (currentPdfDoc) {
|
||||
const page = pdfCurrentPage || 1;
|
||||
label = `Seite ${pdfPageLabel(page)}`;
|
||||
const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1;
|
||||
label = `Seite ${page}`;
|
||||
anchor = `pdf-page-${page}`;
|
||||
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
|
||||
} else {
|
||||
|
|
@ -143,7 +143,15 @@ function jumpToBookmark(id) {
|
|||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
if (bm.anchor.startsWith('pdf-page-')) {
|
||||
if (readerSettings.pdfPaginated) {
|
||||
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
|
||||
} else {
|
||||
const target = contentEl.querySelector('#' + bm.anchor);
|
||||
if (target) {
|
||||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
|
||||
.find(el => el.getAttribute('data-epub-src') === bm.anchor);
|
||||
|
|
|
|||
|
|
@ -94,9 +94,15 @@ async function doReaderSearch(query) {
|
|||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
// PDF: the whole document, page by page (not only the pages drawn now).
|
||||
_pdfSearchAll(query);
|
||||
return;
|
||||
// PDF: collect text layer spans
|
||||
const spans = contentEl.querySelectorAll('.pdf-text-layer > span');
|
||||
const lq = query.toLowerCase();
|
||||
for (const span of spans) {
|
||||
if (span.textContent.toLowerCase().includes(lq)) {
|
||||
span.classList.add('reader-search-match');
|
||||
searchMatches.push(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0';
|
||||
|
|
@ -125,7 +131,10 @@ function clearReaderSearchHighlights() {
|
|||
});
|
||||
}
|
||||
} else {
|
||||
_pdfSearchStop();
|
||||
// PDF: remove highlight class from spans
|
||||
document.querySelectorAll('.reader-search-match').forEach(el => {
|
||||
el.classList.remove('reader-search-match', 'active');
|
||||
});
|
||||
}
|
||||
searchMatches = [];
|
||||
searchMatchIndex = -1;
|
||||
|
|
@ -141,7 +150,6 @@ function clearReaderSearch() {
|
|||
|
||||
function scrollToSearchMatch(idx) {
|
||||
if (!searchMatches.length) return;
|
||||
if (currentPdfDoc) { _pdfShowMatch(idx); return; }
|
||||
_suppressScrollJumpDetect();
|
||||
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
|
||||
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
|
|
@ -149,134 +157,6 @@ function scrollToSearchMatch(idx) {
|
|||
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() {
|
||||
if (!searchMatches.length) return;
|
||||
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;
|
||||
|
|
|
|||
|
|
@ -219,9 +219,7 @@ function handleReaderSelection(e) {
|
|||
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;
|
||||
if (!markerModeActive) return;
|
||||
// Don't hijack taps on real interactive content (footnote refs, external
|
||||
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
|
||||
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml
|
||||
|
|
|
|||
|
|
@ -1,131 +1,41 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footnotes: finding a reference's note, and the popover that shows it
|
||||
// Footnote popover
|
||||
// ---------------------------------------------------------------------------
|
||||
// The whole book is one concatenated DOM, and ids like "fn1" repeat per
|
||||
// chapter file: a note is looked up in the chapter its link points into (the
|
||||
// link's original href, kept in data-footnote-href by sanitizeEpubHtml), and
|
||||
// only exactly the note is shown — not the parent around an id that sits on
|
||||
// the note's own <p>/<aside>/<li>, which can be a whole chapter. The same
|
||||
// lookup serves the popover here, the notes at the foot of a page
|
||||
// (63-pages.js) and the apps' note sheet (diora-android native-ui.js).
|
||||
|
||||
const _NOTE_INLINE = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL|CODE)$/;
|
||||
|
||||
function noteHrefOf(link) {
|
||||
return link.dataset.footnoteHref || ('#' + (link.dataset.footnoteRef || ''));
|
||||
}
|
||||
|
||||
function _noteChapterFor(fromEl, href) {
|
||||
const chapter = fromEl.closest('[data-epub-src]');
|
||||
const file = href.split('#')[0];
|
||||
if (!file || !chapter) return chapter;
|
||||
const src = chapter.dataset.epubSrc || '';
|
||||
const dir = src.includes('/') ? src.slice(0, src.lastIndexOf('/') + 1) : '';
|
||||
const target = resolveEpubPath(dir, file);
|
||||
return document.querySelector(`#reader-content > [data-epub-src="${CSS.escape(target)}"]`) || chapter;
|
||||
}
|
||||
|
||||
// The element `href` (as written in the book, relative to fromEl's chapter) points at.
|
||||
function resolveNoteTarget(fromEl, href) {
|
||||
const id = decodeURIComponent(href.slice(href.indexOf('#') + 1));
|
||||
const scope = _noteChapterFor(fromEl, href);
|
||||
const content = document.getElementById('reader-content');
|
||||
let el = id && scope ? scope.querySelector(`[id="${CSS.escape(id)}"]`) : null;
|
||||
if (!el && id && content) el = content.querySelector(`[id="${CSS.escape(id)}"]`);
|
||||
if (!el && !id) el = scope; // link to a whole chapter file
|
||||
return el;
|
||||
}
|
||||
|
||||
// Converters (zeno.org / e-artnow, some Calibre output) park an empty
|
||||
// <a id="F537"></a> right *before* the note's paragraph instead of inside it —
|
||||
// or at the start of it (then the paragraph is the note, not what follows).
|
||||
function _noteAfterEmptyAnchor(target) {
|
||||
if ((target.textContent || '').trim() || target.querySelector('img')) return null;
|
||||
const own = target.closest('p, li, dd, dt, td, blockquote');
|
||||
const ownText = own && !own.hasAttribute('data-epub-src') ? (own.textContent || '').trim() : '';
|
||||
if (ownText && !/^[\[(]?\d{1,4}[\])]?\.?$/.test(ownText)) return own;
|
||||
if (ownText) {
|
||||
// Only the number: the note (a poem, a long quote) runs on in the next
|
||||
// paragraphs, up to the next note's anchor.
|
||||
const box = document.createElement('div');
|
||||
box.appendChild(own.cloneNode(true));
|
||||
let len = 0;
|
||||
for (let next = own.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
|
||||
if (next.matches('h1, h2, h3, h4, h5, h6') || next.querySelector('[id]')) break;
|
||||
if (!(next.textContent || '').trim()) continue;
|
||||
box.appendChild(next.cloneNode(true));
|
||||
len += next.textContent.length;
|
||||
}
|
||||
return box;
|
||||
}
|
||||
for (let el = target; el && !el.hasAttribute('data-epub-src'); el = el.parentElement) {
|
||||
let next = el.nextElementSibling;
|
||||
while (next && !(next.textContent || '').trim()) next = next.nextElementSibling;
|
||||
if (next) return next.matches('h1, h2, h3, h4, h5, h6') ? null : next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _noteBox(target) {
|
||||
const following = _noteAfterEmptyAnchor(target);
|
||||
if (following) return following;
|
||||
let box = target.closest(_NOTE_BOX_SELECTOR); // 40-epub.js
|
||||
if (box && (box.hasAttribute('data-epub-src') || box.id === 'reader-content')) box = null;
|
||||
if (!box) box = _NOTE_INLINE.test(target.tagName) ? (target.closest('p, li, dd, div, td, blockquote') || target) : target;
|
||||
// Never a whole chapter or section: fall back to the paragraph around the target.
|
||||
if (box.hasAttribute('data-epub-src') || (box.textContent || '').length > 6000) {
|
||||
box = target.closest('p, li, dd') || target;
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
// A copy of the note at `target`, ready to show outside the book.
|
||||
function noteContentFor(target, label) {
|
||||
const clone = _noteBox(target).cloneNode(true);
|
||||
clone.querySelectorAll('[role="doc-backlink"], [epub\\:type~="backlink"]').forEach(e => e.remove());
|
||||
// A page of the printed edition that starts inside the note.
|
||||
clone.querySelectorAll('.epub-pagenum, [role="doc-pagebreak"], [epub\\:type~="pagebreak"]').forEach(e => e.remove());
|
||||
// Links back to the text ("↩", or the note's own number at its start).
|
||||
clone.querySelectorAll('a').forEach(a => {
|
||||
const t = (a.textContent || '').trim();
|
||||
if (/^[↩↑⤴^]+$/.test(t)) a.remove();
|
||||
});
|
||||
// The note's own number at its very start ("3 Endnote…", "[4] …"): whatever
|
||||
// shows the note labels it already.
|
||||
const first = Array.prototype.find.call(clone.querySelectorAll('a, sup, span, b, strong'),
|
||||
e => (e.textContent || '').trim());
|
||||
const firstText = first ? (first.textContent || '').trim() : '';
|
||||
const bare = t => t.replace(/[\[\]().\s]/g, '');
|
||||
if (first && firstText && (clone.textContent || '').trim().startsWith(firstText) &&
|
||||
(bare(firstText) === bare(label || '') || /^[\[(]?(\d{1,4}|[*†‡§]+)[\])]?\.?$/.test(firstText))) {
|
||||
first.remove();
|
||||
// …and the space that followed it.
|
||||
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null, false);
|
||||
const t = walker.nextNode();
|
||||
if (t) t.nodeValue = t.nodeValue.replace(/^\s+/, '');
|
||||
}
|
||||
// No duplicate ids in the page (getElementById must keep finding the book's).
|
||||
clone.removeAttribute('id');
|
||||
clone.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
|
||||
clone.querySelectorAll('mark.tts-current, mark.reader-search-match').forEach(m => m.replaceWith(...m.childNodes));
|
||||
return clone;
|
||||
}
|
||||
|
||||
// The note of a reference link, or null.
|
||||
function noteForLink(link) {
|
||||
const target = resolveNoteTarget(link, noteHrefOf(link));
|
||||
return target ? noteContentFor(target, (link.textContent || '').trim()) : null;
|
||||
}
|
||||
|
||||
let currentFootnotePopover = null;
|
||||
let _footnotePopoverTimer = null;
|
||||
|
||||
function showFootnotePopover(link) {
|
||||
dismissFootnotePopover();
|
||||
const note = noteForLink(link);
|
||||
if (!note) return;
|
||||
const targetEl = document.getElementById(link.dataset.footnoteRef);
|
||||
if (!targetEl) return;
|
||||
// The note's paragraph, also when the id sits on an inline element in it
|
||||
// (<p><span><a id="F400" …>143</a></span> Note…</p>).
|
||||
const inline = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL)$/.test(targetEl.tagName);
|
||||
const container = targetEl.closest('.footnote') ||
|
||||
(inline && targetEl.closest('p, li, dd, blockquote')) || targetEl.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const clone = container.cloneNode(true);
|
||||
clone.querySelector('sup')?.remove();
|
||||
clone.querySelectorAll('.epub-pagenum').forEach(e => e.remove());
|
||||
// The note's own number at its start, a link back to the text ("1 Note…").
|
||||
const first = Array.from(clone.querySelectorAll('a')).find(a => a.textContent.trim());
|
||||
const num = first ? first.textContent.trim() : '';
|
||||
if (/^[\[(]?\d{1,4}[\])]?\.?$/.test(num) && clone.textContent.trim().startsWith(num)) first.remove();
|
||||
// Only the number: the note (a poem, a long quote) runs on in the next
|
||||
// paragraphs, up to the next note's anchor.
|
||||
if (!clone.textContent.trim()) {
|
||||
let len = 0;
|
||||
for (let next = container.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
|
||||
if (/^H[1-6]$/.test(next.tagName) || next.querySelector('[id]')) break;
|
||||
if (!next.textContent.trim()) continue;
|
||||
clone.appendChild(next.cloneNode(true));
|
||||
len += next.textContent.length;
|
||||
}
|
||||
}
|
||||
|
||||
const popover = document.createElement('div');
|
||||
popover.className = 'footnote-popover';
|
||||
|
|
@ -133,7 +43,7 @@ function showFootnotePopover(link) {
|
|||
['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => {
|
||||
if (_overlay?.classList.contains(cls)) popover.classList.add(cls);
|
||||
});
|
||||
popover.appendChild(note);
|
||||
popover.innerHTML = clone.innerHTML;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'footnote-popover-close';
|
||||
|
|
@ -156,7 +66,7 @@ function showFootnotePopover(link) {
|
|||
|
||||
const pr = popover.getBoundingClientRect();
|
||||
if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px';
|
||||
if (pr.bottom > window.innerHeight - 8) popover.style.top = Math.max(8, rect.top - pr.height - 8) + 'px';
|
||||
if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px';
|
||||
}
|
||||
|
||||
function dismissFootnotePopover() {
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@
|
|||
folder: null, // the folder chip chosen (null: all, '': without a folder)
|
||||
folderOrder: [], // the podcasts' folder order (/podcasts/folders/)
|
||||
series: null, // the series shown (its key), inside the folder
|
||||
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');
|
||||
|
|
@ -179,6 +175,45 @@
|
|||
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) ----------------
|
||||
|
||||
// "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12":
|
||||
|
|
@ -235,131 +270,28 @@
|
|||
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) {
|
||||
return '<div class="lib-card" data-ab-open="' + b.feed_id + '">' +
|
||||
'<span class="lib-coverbox">' + cover(b.artwork_url, b.title, 'lib-cover', b.author) +
|
||||
(heard(b) ? '<span class="lib-badge">✓</span>' : '') + '</span>' +
|
||||
'<span class="lib-bar"><span style="width:' + pct(b) + '%"></span></span>' +
|
||||
'<span class="lib-title">' + esc(label || b.title) + '</span>' +
|
||||
'<span class="lib-meta">' + esc(label ? progressText(b) : (b.author || progressText(b))) + '</span></div>';
|
||||
return '<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
|
||||
cover(b.artwork_url, b.title, 'ab-cover') +
|
||||
'<span class="ab-card-text"><span class="ab-title">' + esc(label || b.title) + '</span>' +
|
||||
(b.author && !label ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
|
||||
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
|
||||
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>';
|
||||
}
|
||||
|
||||
function seriesCard(g) {
|
||||
const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0);
|
||||
const heardSecs = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
|
||||
const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
|
||||
const going = g.books.filter(b => b.next && b.last_played_at)[0];
|
||||
const done = g.books.filter(heard).length;
|
||||
const done = g.books.filter(b => !b.next).length;
|
||||
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume
|
||||
: done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : '');
|
||||
return '<div class="lib-card lib-stack" data-ab-series="' + esc(g.key) + '">' +
|
||||
'<span class="lib-coverbox lib-stackbox">' + cover(g.books[0].artwork_url, g.name, 'lib-cover', g.books[0].author) + '</span>' +
|
||||
'<span class="lib-bar"><span style="width:' + (total ? Math.round(100 * heardSecs / total) : 0) + '%"></span></span>' +
|
||||
'<span class="lib-title">' + esc(g.name) + '</span>' +
|
||||
'<span class="lib-meta">' + esc(meta) + '</span></div>';
|
||||
return '<button type="button" class="ab-card ab-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="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' +
|
||||
(g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') +
|
||||
'<span class="ab-meta">' + esc(meta) + '</span>' +
|
||||
'<span class="ab-bar"><span style="width:' + (total ? Math.round(100 * heard / total) : 0) + '%"></span></span></span></button>';
|
||||
}
|
||||
|
||||
const foldersOf = b => (b.folders && b.folders.length ? b.folders : (b.folder ? [b.folder] : []));
|
||||
|
|
@ -379,24 +311,32 @@
|
|||
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) {
|
||||
const el = shelf.querySelector('.lib-chips');
|
||||
const el = shelf.querySelector('.ab-chips');
|
||||
if (!el || !window.DioraFolders) return;
|
||||
if (!books.length) { el.innerHTML = ''; return; }
|
||||
const c = folderChips(books);
|
||||
if (!c.folders.length) { el.innerHTML = ''; return; }
|
||||
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,
|
||||
onPick(p) {
|
||||
if (p && p.lead) st.status = st.status === p.lead ? 'all' : p.lead;
|
||||
else st.folder = p;
|
||||
renderShelf();
|
||||
},
|
||||
onManage: c.folders.length ? manageFolders : null,
|
||||
onPick(name) { st.folder = name; renderShelf(); },
|
||||
onManage: manageFolders,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -502,7 +442,7 @@
|
|||
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 b = d ? d.book : fromList;
|
||||
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
|
||||
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
|
||||
const now = nowInBook();
|
||||
const marks = d ? d.bookmarks : [];
|
||||
const dlMap = d ? downloads() : null;
|
||||
|
|
@ -512,7 +452,7 @@
|
|||
return ch ? 'Kapitel ' + (ch.index + 1) : '';
|
||||
};
|
||||
view.innerHTML =
|
||||
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Hörbücher</button>' +
|
||||
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button>' +
|
||||
'<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' +
|
||||
'<div class="ab-menu" hidden>' +
|
||||
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
|
||||
|
|
@ -823,9 +763,8 @@
|
|||
}
|
||||
|
||||
shelf.addEventListener('click', e => {
|
||||
const t = e.target.closest('button, .lib-continue, .lib-card');
|
||||
const t = e.target.closest('button, .ab-continue');
|
||||
if (!t) return;
|
||||
if (t.dataset.ab === 'search') { st.searching = true; renderShelf(); 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.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; }
|
||||
|
|
@ -853,18 +792,7 @@
|
|||
if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; }
|
||||
if (t.dataset.ab === 'pick-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 => {
|
||||
if (e.target.classList.contains('lib-q')) {
|
||||
st.q = e.target.value;
|
||||
renderShelf();
|
||||
return;
|
||||
}
|
||||
if (!e.target.classList.contains('ab-pick-q')) return;
|
||||
st.pickQuery = e.target.value;
|
||||
const box = shelf.querySelector('.ab-pick-list');
|
||||
|
|
|
|||
|
|
@ -122,20 +122,16 @@
|
|||
|
||||
async function pdfCover(bytes) {
|
||||
if (typeof pdfjsLib === 'undefined') return null;
|
||||
const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes))
|
||||
: pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise;
|
||||
const canvas = document.createElement('canvas');
|
||||
try {
|
||||
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const v = page.getViewport({scale: 1});
|
||||
const scale = Math.min(MAX_W / v.width, MAX_H / v.height);
|
||||
const viewport = page.getViewport({scale});
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(viewport.width);
|
||||
canvas.height = Math.round(viewport.height);
|
||||
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
||||
} finally {
|
||||
try { pdf.destroy(); } catch (e) {}
|
||||
}
|
||||
pdf.destroy();
|
||||
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -603,35 +603,59 @@
|
|||
|
||||
// ---- article view
|
||||
|
||||
// ---- 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 reader = null; // the overlay element
|
||||
let readerList = []; // what "next" walks through (blog items)
|
||||
let readerStory = 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() {
|
||||
return reading && !!(window.DioraArticle && DioraArticle.isOpen());
|
||||
return !!(reader && !reader.hidden);
|
||||
}
|
||||
|
||||
function closeReader() {
|
||||
if (window.DioraArticle) DioraArticle.close();
|
||||
}
|
||||
|
||||
function articleClosed() {
|
||||
reading = false;
|
||||
if (!reader) return;
|
||||
saveProgress();
|
||||
reader.hidden = true;
|
||||
reader.innerHTML = '';
|
||||
document.documentElement.classList.remove('fd-reading');
|
||||
readerItem = readerStory = null;
|
||||
render();
|
||||
}
|
||||
|
||||
// How far the open article is read (0-1, from the reader); saved now and then and when leaving it.
|
||||
// How far the open article is read (0-1); saved now and then and when leaving it.
|
||||
let progressSent = 0;
|
||||
let progressTimer = null;
|
||||
|
||||
function saveProgress(f) {
|
||||
function readProgress() {
|
||||
if (!reader || reader.hidden) return 0;
|
||||
const room = reader.scrollHeight - reader.clientHeight;
|
||||
return room <= 40 ? 1 : Math.min(1, reader.scrollTop / room);
|
||||
}
|
||||
|
||||
function saveProgress() {
|
||||
const i = readerItem;
|
||||
if (!i) return;
|
||||
const p = Math.round(f * 100) / 100;
|
||||
const p = Math.round(readProgress() * 100) / 100;
|
||||
if (Math.abs(p - progressSent) < 0.02 && !(p >= 0.9 && !i.read)) return;
|
||||
progressSent = p;
|
||||
i.progress = p;
|
||||
|
|
@ -643,68 +667,51 @@
|
|||
api('/feeds/items/' + i.id + '/progress/', {progress: p}).catch(() => {});
|
||||
}
|
||||
|
||||
// Opens a link outside, like the article's own links (the app hands them to the browser).
|
||||
function openOutside(url) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
// The article's own buttons, in the reader's header (the apps: in its menu).
|
||||
function articleActions(i) {
|
||||
return [
|
||||
{icon: i.starred ? '★' : '☆', label: i.starred ? 'Gemerkt' : 'Merken', active: !!i.starred, action: () => toggleStar(i)},
|
||||
// Projekte (projects.js): this article as a source of a project.
|
||||
window.DioraProjects && {icon: '◇', label: 'Mit Projekt verknüpfen', action: () => window.DioraProjects.link('article', i.id, {
|
||||
title: i.title, source: i.source_title, author: i.author, date: i.published, url: i.url, news: i.kind === 'news'})},
|
||||
i.url && {icon: '↗', label: 'Im Browser öffnen', action: () => openOutside(i.url)},
|
||||
];
|
||||
}
|
||||
|
||||
function toggleStar(i) {
|
||||
i.starred = !i.starred;
|
||||
api('/feeds/items/' + i.id + '/star/', {starred: i.starred}).catch(() => {});
|
||||
st.items.forEach(x => { if (x.id === i.id) x.starred = i.starred; });
|
||||
if (window.DioraArticle) DioraArticle.refreshActions();
|
||||
}
|
||||
|
||||
async function openItem(id, story) {
|
||||
if (!window.DioraArticle) return;
|
||||
ensureReader();
|
||||
if (readerItem) saveProgress();
|
||||
readerStory = story || null;
|
||||
let item = null;
|
||||
let error = '';
|
||||
reader.hidden = false;
|
||||
document.documentElement.classList.add('fd-reading');
|
||||
reader.innerHTML = readerShell('<p class="fd-note">Lädt …</p>');
|
||||
reader.scrollTop = 0;
|
||||
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),
|
||||
});
|
||||
const data = await api('/feeds/items/' + id + '/');
|
||||
readerItem = data.item;
|
||||
reader.innerHTML = readerShell(articleHtml(readerItem));
|
||||
reader.scrollTop = 0;
|
||||
if (readerStory) loadOverview(readerStory);
|
||||
if (item.kind === 'news' && !item.read) {
|
||||
item.read = true;
|
||||
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) {
|
||||
const i = readerItem;
|
||||
return '<div class="fd-art-bar">' +
|
||||
'<button type="button" class="fd-icon" data-r="close" aria-label="Zurück" title="Zurück">‹</button>' +
|
||||
'<span class="fd-art-src">' + esc(i ? i.source_title : '') + '</span>' +
|
||||
'<button type="button" class="fd-icon" data-r="font" aria-label="Schriftgröße" title="Schriftgröße">Aa</button>' +
|
||||
(i ? '<button type="button" class="fd-icon' + (i.starred ? ' on' : '') + '" data-r="star" aria-label="Merken" title="Merken">' + (i.starred ? '★' : '☆') + '</button>' : '') +
|
||||
// Projekte (projects.js): this article as a source of a project.
|
||||
(i && window.DioraProjects ? '<button type="button" class="fd-icon" data-r="project" aria-label="Mit Projekt verknüpfen" title="Mit Projekt verknüpfen">◇</button>' : '') +
|
||||
(i && i.url ? '<a class="fd-icon" href="' + esc(i.url) + '" target="_blank" rel="noopener" aria-label="Im Browser öffnen" title="Im Browser öffnen">↗</a>' : '') +
|
||||
'</div><div class="fd-art" style="font-size:' + fontSize() + 'px">' + inner + '</div>';
|
||||
}
|
||||
|
||||
function articleHtml(i) {
|
||||
|
|
@ -720,10 +727,6 @@
|
|||
if (story) h += '<div class="fd-ov">' + (story.overview ? overviewHtml(story) : '') + '</div>';
|
||||
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.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>';
|
||||
if (i.paywalled) {
|
||||
h += '<p class="fd-note">Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) +
|
||||
|
|
@ -784,7 +787,7 @@
|
|||
} catch (e) {
|
||||
return; // offline and never fetched: the article alone
|
||||
}
|
||||
const box = readerOpen() && readerStory === story && document.querySelector('#reader-content .fd-ov');
|
||||
const box = reader && !reader.hidden && readerStory === story && reader.querySelector('.fd-ov');
|
||||
if (box) box.innerHTML = overviewHtml(story);
|
||||
}
|
||||
|
||||
|
|
@ -804,76 +807,37 @@
|
|||
});
|
||||
}
|
||||
|
||||
// ---- the episode of a podcast article (docs/transcription.md)
|
||||
|
||||
function podcastHost() {
|
||||
return window.DioraPodcastHost || window.DioraNative || null;
|
||||
}
|
||||
|
||||
function parseJson(text, fallback) {
|
||||
try { return JSON.parse(text); } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
// Play the episode from `seconds` (null: open its page, where ▶ starts it). The browser and the
|
||||
// diora desktop have the host of podcasts-web.js; the apps' bridge has the same calls
|
||||
// (podcastPlayEpisodeAt is the one that starts at a place; without it the episode starts at its own place).
|
||||
async function playEpisode(episodeId, seconds) {
|
||||
const host = podcastHost();
|
||||
if (!host || !host.playerState) { alert('Der Podcast-Bereich ist hier nicht verfügbar.'); return; }
|
||||
const now = parseJson(host.playerState(), {});
|
||||
const ms = seconds == null ? null : Math.max(0, Math.round(seconds * 1000));
|
||||
let opened = false;
|
||||
if (now.kind === 'podcast' && now.episodeId === episodeId) {
|
||||
if (ms != null && host.podcastSeek) host.podcastSeek(ms);
|
||||
if (ms != null && !now.playing && host.podcastToggle) host.podcastToggle();
|
||||
opened = ms != null;
|
||||
} else {
|
||||
let d;
|
||||
try {
|
||||
d = await api('/podcasts/episodes/' + episodeId + '/');
|
||||
} catch (e) {
|
||||
alert(e.message === 'Failed to fetch' ? 'Keine Verbindung.' : 'Die Folge ist nicht mehr da.');
|
||||
return;
|
||||
}
|
||||
const row = {id: d.id, title: d.title, audio_url: d.audio_url, duration_seconds: d.duration_seconds,
|
||||
artwork_url: d.artwork_url, pub_date: d.pub_date, position_seconds: d.position_seconds,
|
||||
feed__id: d.feed.id, feed__title: d.feed.title, feed__artwork_url: d.feed.artwork_url};
|
||||
closeReader();
|
||||
if (ms != null && host.podcastPlayEpisodeAt) {
|
||||
host.podcastPlayEpisodeAt(JSON.stringify(row), now.episodeId || 0, ms);
|
||||
opened = true;
|
||||
} else if (ms == null && window.__dioraPodcastOpenEpisode && window.__dioraPodcastOpenEpisode(row)) {
|
||||
return;
|
||||
} else if (host.podcastPlayEpisode) {
|
||||
host.podcastPlayEpisode(JSON.stringify(row), now.episodeId || 0);
|
||||
opened = true;
|
||||
}
|
||||
}
|
||||
closeReader();
|
||||
if (opened && window.__dioraOpenPlayer) window.__dioraOpenPlayer('podcast');
|
||||
}
|
||||
|
||||
// Clicks in the article (65-article.js hands them over): its other reports, the next article.
|
||||
function onReaderClick(e) {
|
||||
// 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);
|
||||
}
|
||||
const b = e.target.closest('[data-r]');
|
||||
if (!b) {
|
||||
// Links in the article open outside (the app hands them to the browser).
|
||||
const a = e.target.closest('.fd-art-body a[href]');
|
||||
if (a) { a.target = '_blank'; a.rel = 'noopener'; }
|
||||
return;
|
||||
}
|
||||
const b = e.target.closest('[data-r]');
|
||||
if (!b) return;
|
||||
const r = b.dataset.r;
|
||||
if (r === 'episode') playEpisode(parseInt(b.dataset.id, 10), null);
|
||||
if (r === 'close') closeReader();
|
||||
else if (r === 'item') openItem(parseInt(b.dataset.id, 10), readerStory);
|
||||
else if (r === 'story') {
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
|
|
@ -1390,6 +1354,9 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
|
|||
}
|
||||
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
|
||||
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
|
||||
|
|
@ -1469,9 +1436,9 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
|
|||
'.fd-sources { display: flex; gap: 6px; margin: 4px -18px 10px; padding: 0 18px; align-items: center; overflow-x: auto; white-space: nowrap; scrollbar-width: none; }',
|
||||
'.fd-sources::-webkit-scrollbar { display: none; }',
|
||||
'.fd-sources .fd-chip { flex: none; }',
|
||||
'.fd-artview .fd-art-more { font-family: var(--font); margin: 30px 0 4px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
|
||||
'.fd-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-artview .fd-other-title { font-weight: 600; line-height: 1.35; }',
|
||||
'#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-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-article .fd-other-title { font-weight: 600; line-height: 1.35; }',
|
||||
'.fd-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
||||
'.fd-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); }',
|
||||
'.fd-chip small { color: var(--nui-muted, #9a9a9a); font-size: 11px; }',
|
||||
|
|
@ -1524,39 +1491,45 @@ und Ordner. Schlage nur Änderungen vor, die wirklich helfen; eine Quelle ohne s
|
|||
'#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }',
|
||||
'#tab-feeds .fd-keep { display: flex; align-items: center; gap: 8px; margin-top: 14px; }',
|
||||
'#tab-feeds .fd-file { display: inline-block; }',
|
||||
// The article, in the book reader (65-article.js).
|
||||
'.fd-artview .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
|
||||
'.fd-artview .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
|
||||
'.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-artview .fd-art-listen { margin: 0 0 12px; }',
|
||||
'.fd-artview .fd-art-body a { color: var(--accent, #e63946); }',
|
||||
'.fd-artview .fd-art-body figure { margin: 12px 0; }',
|
||||
'.fd-artview .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
|
||||
'.fd-artview .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
|
||||
'.fd-artview .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
|
||||
'.fd-artview .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
|
||||
'.fd-artview .fd-art-body td, .fd-artview .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
|
||||
'.fd-artview .fd-sources { font-family: var(--font); }',
|
||||
// The article: a page over everything, like the book reader.
|
||||
'#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-article[hidden] { display: none; }',
|
||||
'#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-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-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-article .fd-art-title { font-size: 1.45em; line-height: 1.25; margin: 10px 0 6px; font-weight: 700; }',
|
||||
'#fd-article .fd-art-meta { font-family: var(--font); font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 0 0 14px; }',
|
||||
'#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-article .fd-art-body a { color: var(--accent, #e63946); }',
|
||||
'#fd-article .fd-art-body figure { margin: 12px 0; }',
|
||||
'#fd-article .fd-art-body figcaption { font-size: 0.8em; color: var(--nui-muted, #9a9a9a); }',
|
||||
'#fd-article .fd-art-body blockquote { margin: 12px 0; padding-left: 14px; border-left: 3px solid var(--border-strong, #444); }',
|
||||
'#fd-article .fd-art-body pre { white-space: pre-wrap; font-size: 0.85em; }',
|
||||
'#fd-article .fd-art-body table { display: block; overflow-x: auto; border-collapse: collapse; font-size: 0.85em; }',
|
||||
'#fd-article .fd-art-body td, #fd-article .fd-art-body th { border: 1px solid var(--border, #333); padding: 4px 6px; }',
|
||||
'#fd-article .fd-sources { font-family: var(--font); }',
|
||||
'#tab-feeds .fd-topics .fd-note { margin-right: 2px; }',
|
||||
'#tab-feeds .fd-topics small { opacity: 0.6; }',
|
||||
'.fd-artview .fd-ov { font-size: 0.9em; }',
|
||||
'.fd-artview .fd-ov-box { margin: 0 0 18px; padding: 10px 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; }',
|
||||
'.fd-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-artview .fd-ov p { margin: 8px 0; line-height: 1.45; }',
|
||||
'.fd-artview .fd-ov h4 { font-family: var(--font); font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); margin: 14px 0 4px; }',
|
||||
'.fd-artview .fd-ov-src { font-family: var(--font); font-size: 11px; color: var(--nui-muted, #9a9a9a); white-space: nowrap; }',
|
||||
'.fd-artview .fd-ov-words { font-family: var(--font); font-size: 13px; }',
|
||||
'.fd-artview .fd-ov-rel { margin: 0 0 18px; }',
|
||||
'.fd-artview .fd-art-foot { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; margin-top: 28px; font-family: var(--font); }',
|
||||
'#fd-article .fd-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-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-article .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-article .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-article .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); }',
|
||||
'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); } }',
|
||||
// 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.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 .fd-row-meta, body.eink .fd-note, body.eink #tab-feeds .fd-card-sum, body.eink #tab-feeds .fd-read .fd-card-title, body.eink #tab-feeds .fd-read .fd-row-title { color: #333; }',
|
||||
'body.eink .fd-artview .fd-ov-box { border-color: #000; }',
|
||||
'body.eink .fd-artview .fd-ov-src, body.eink .fd-artview .fd-ov h4, body.eink .fd-artview .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, body.eink #fd-article .fd-art-bar { background: #fff; color: #000; }',
|
||||
'body.eink #fd-article .fd-ov-box { border-color: #000; }',
|
||||
'body.eink #fd-article .fd-ov-src, body.eink #fd-article .fd-ov h4, body.eink #fd-article .fd-ov-box summary { color: #333; }',
|
||||
'body.eink #fd-article .fd-art-body a, body.eink .fd-chip.on, body.eink .fd-icon.on, body.eink #tab-feeds .fd-brief-head h2 { color: #000; }',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -22,45 +22,22 @@
|
|||
// 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.
|
||||
|
||||
// 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) {
|
||||
const lead = o.lead || [];
|
||||
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">';
|
||||
if (!lead.length) h += chip('dfo-all', 'Alle', null, o.active == null && !leadOn, 'data-dfo-all="1"');
|
||||
lead.forEach(c => { h += chip('', c.label, c.count, !!c.on, 'data-dfo-lead="' + esc(c.id) + '"'); });
|
||||
if (lead.length && folders.length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
|
||||
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;
|
||||
if (lead.length && (o.folders || []).length) h += '<span class="dfo-sep" aria-hidden="true"></span>';
|
||||
(o.folders || []).forEach(f => {
|
||||
h += chip('', f.name, f.count, o.active === f.name, 'data-dfo-folder="' + esc(f.name) + '"');
|
||||
});
|
||||
if (o.none && (!fold || o.active === '')) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
|
||||
if (o.onManage && !fold) h += '<button type="button" class="dfo-chip dfo-manage" data-dfo-manage="1" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>';
|
||||
if (o.none) h += chip('dfo-none', 'Ohne Ordner', o.none, o.active === '', 'data-dfo-folder=""');
|
||||
if (o.onManage) h += '<button type="button" class="dfo-chip dfo-manage" data-dfo-manage="1" title="Ordner verwalten" aria-label="Ordner verwalten">⋯</button>';
|
||||
el.innerHTML = h + '</div>';
|
||||
el.onclick = e => {
|
||||
const b = e.target.closest('button');
|
||||
if (!b) return;
|
||||
if (b.dataset.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.dfoAll) { o.onPick(null); return; }
|
||||
if (b.dataset.dfoLead != null) { o.onPick({lead: b.dataset.dfoLead}); return; }
|
||||
|
|
@ -211,7 +188,6 @@
|
|||
'.dfo-count { opacity: .7; font-size: 12px; }',
|
||||
'.dfo-none { font-style: italic; }',
|
||||
'.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; }',
|
||||
// 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; }',
|
||||
|
|
|
|||
|
|
@ -1,380 +0,0 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
// Podcast articles on the device (docs/transcription.md): the transcript of an episode goes to
|
||||
// Claude with the user's own key (DioraAI, docs/ai.md) and comes back as an article that is saved
|
||||
// to the episode and to the blog "Podcast-Artikel". Also the picker-up of open wishes: the server
|
||||
// only remembers that an article was wanted; a device with a key writes it.
|
||||
//
|
||||
// An ES module like ai.js. window.DioraPodcastArticle = {write, pickUp, watch, ...}; the helpers
|
||||
// are exported too (tools/tests/podcast-article.test.mjs runs them under Node).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MAX_TOKENS_IN = 120000; // about nine hours of speech; more stops with a message
|
||||
export const GROUP_SECONDS = 30;
|
||||
const SHOWNOTES_MAX = 6000; // characters of the shownotes handed to Claude
|
||||
const POLL_MS = 5 * 60 * 1000;
|
||||
|
||||
const ai = () => globalThis.DioraAI;
|
||||
|
||||
export const SYSTEM = `You turn the transcript of a podcast episode into an article that reads as if the people
|
||||
speaking had written it themselves instead of recording the episode.
|
||||
|
||||
The reader wants to decide whether this episode still has something to teach them. They do not
|
||||
want a summary; they want to understand what is argued here well enough to judge it for
|
||||
themselves. Write for that reader.
|
||||
|
||||
Perspective. Write from the speakers' standpoint and state their positions directly, as their
|
||||
own claims. Never distance yourself from them: no "im Podcast wird die These aufgestellt", "der
|
||||
Sprecher meint", "in dieser Folge geht es um", "die Gäste diskutieren", "laut X". If the episode
|
||||
is one person speaking, write as that person argues, in the third person or impersonal voice,
|
||||
but without reporting verbs. Do not add your own opinion, criticism or fact checks, and do not
|
||||
soften what is said.
|
||||
|
||||
Conversations. Interviews, conversations and discussions are condensed into their core theses;
|
||||
the form of question and answer disappears. Where people hold different positions, keep them
|
||||
apart and name who holds which ("Zuckermann hält dagegen, dass …"), but do not turn it into
|
||||
minutes. Questions from a host matter only where they shape the argument. When the transcript
|
||||
has no speaker names, infer them from the context (introductions, the shownotes, how people
|
||||
address each other); if you can't, do not invent names.
|
||||
|
||||
Substance over summary. Give the arguments with their reasons, examples and evidence, not just
|
||||
the conclusions. Keep figures, dates, quotations that carry an argument (short, in quotation
|
||||
marks, attributed), and every work mentioned (books, articles, studies, films) with author and
|
||||
title. Make clear what is new, distinctive or surprising in the episode and where it restates
|
||||
what is commonly said, by how you weight and order it, without grading it.
|
||||
|
||||
Leave out: advertising and sponsors, appeals for donations, subscriptions or ratings, notes on
|
||||
the podcast itself (schedules, technology, community), greetings, small talk and digressions that
|
||||
lead nowhere.
|
||||
|
||||
Form. Write in the language of the episode. \`title\`: a title for the article, not the episode
|
||||
title repeated. \`summary\`: three to six sentences up front that state the central thesis and how
|
||||
the episode gets there. \`sections\`: as many as the content needs (usually four to ten), each
|
||||
with a heading that says something (not "Einleitung"), \`start\` = the second in the episode where
|
||||
its content begins (from the [mm:ss] marks), and \`body_markdown\` in plain paragraphs; lists only
|
||||
where the speakers list things themselves. Follow the order of the episode unless a different
|
||||
order makes the argument clearer. Length: as long as needed for the reader to judge the episode,
|
||||
roughly a tenth of the transcript, at most about 5,000 words.
|
||||
|
||||
The transcript was made by speech recognition: fix obvious mishearings, especially of names,
|
||||
using the metadata, and don't quote garbled passages.`;
|
||||
|
||||
export const SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"title",
|
||||
"summary",
|
||||
"kind",
|
||||
"speakers",
|
||||
"sections"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Interview",
|
||||
"Gespräch",
|
||||
"Monolog",
|
||||
"Diskussion"
|
||||
]
|
||||
},
|
||||
"speakers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"heading",
|
||||
"start",
|
||||
"body_markdown"
|
||||
],
|
||||
"properties": {
|
||||
"heading": {
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"type": "integer"
|
||||
},
|
||||
"body_markdown": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- the input text -------------------------------------------------------------------------
|
||||
|
||||
export const pad = n => String(n).padStart(2, '0');
|
||||
|
||||
// mm:ss (minutes keep counting past 59: "125:03"), the form of the [mm:ss] marks Claude reads.
|
||||
export function mmss(sec) {
|
||||
const s = Math.max(0, Math.floor(Number(sec) || 0));
|
||||
return pad(Math.floor(s / 60)) + ':' + pad(s % 60);
|
||||
}
|
||||
|
||||
// h:mm:ss from an hour on, else mm:ss: the label of the "▶ ab …" links.
|
||||
export function clock(sec) {
|
||||
const s = Math.max(0, Math.floor(Number(sec) || 0));
|
||||
return s >= 3600 ? Math.floor(s / 3600) + ':' + pad(Math.floor(s / 60) % 60) + ':' + pad(s % 60) : mmss(s);
|
||||
}
|
||||
|
||||
// One line per group of about GROUP_SECONDS: "[mm:ss] Sprecher: text", or "[mm:ss] text" without
|
||||
// speakers. A new speaker always starts a new line.
|
||||
export function transcriptToText(segments) {
|
||||
const lines = [];
|
||||
let cur = null;
|
||||
for (const seg of segments || []) {
|
||||
const text = String((seg && seg.text) || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) continue;
|
||||
const start = Number(seg.start) || 0;
|
||||
const speaker = String(seg.speaker || '').trim();
|
||||
if (!cur || speaker !== cur.speaker || start - cur.start >= GROUP_SECONDS) {
|
||||
cur = {start, speaker, parts: []};
|
||||
lines.push(cur);
|
||||
}
|
||||
cur.parts.push(text);
|
||||
}
|
||||
return lines.map(l => '[' + mmss(l.start) + '] ' + (l.speaker ? l.speaker + ': ' : '') + l.parts.join(' ')).join('\n');
|
||||
}
|
||||
|
||||
export const estimateTokens = text => Math.ceil(String(text || '').length / 4);
|
||||
|
||||
// HTML (the shownotes) as plain text.
|
||||
export function htmlToText(html) {
|
||||
return String(html || '')
|
||||
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
|
||||
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|h[1-6]|blockquote|tr)\s*>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '- ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /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.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v171';
|
||||
const CACHE = 'diora-v155';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app/01-core.js',
|
||||
|
|
@ -16,7 +16,6 @@ const SHELL = [
|
|||
'/static/js/app/29-m3u.js',
|
||||
'/static/js/app/30-contrast.js',
|
||||
'/static/js/app/32-crypto.js',
|
||||
'/static/js/app/33-unlock.js',
|
||||
'/static/js/app/34-wallpaper.js',
|
||||
'/static/js/app/40-epub.js',
|
||||
'/static/js/app/44-books.js',
|
||||
|
|
@ -32,8 +31,6 @@ const SHELL = [
|
|||
'/static/js/app/58-pdf.js',
|
||||
'/static/js/app/60-tts.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/66-pdf-paginated.js',
|
||||
'/static/js/app/68-bookmarks.js',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copied from diora-android ba1e933 2026-11-09: web-native/static/native-podcast.js
|
||||
// Copied from diora-android e287f48 2026-10-25: web-native/static/native-podcast.js
|
||||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
||||
// and copy again. The browser host is static/js/podcasts-web.js.
|
||||
|
||||
|
|
@ -273,47 +273,6 @@
|
|||
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
|
||||
}
|
||||
|
||||
// ---- 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).
|
||||
async function getJson(url) {
|
||||
const r = await fetch(url);
|
||||
|
|
@ -622,7 +581,6 @@
|
|||
{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)},
|
||||
favoriteItem(e),
|
||||
shareItem(e),
|
||||
!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 unten', action: () => moveTo(e, 'bottom')},
|
||||
|
|
@ -637,10 +595,6 @@
|
|||
]);
|
||||
}
|
||||
|
||||
function shareItem(e) {
|
||||
return {icon: '⇪', label: 'Teilen', hint: N && N.shareText ? null : 'Link in die Zwischenablage', action: () => shareEpisode(e)};
|
||||
}
|
||||
|
||||
function episodeMenu(e, opts) {
|
||||
if (v2()) { listsMenu(e, opts); return; }
|
||||
const queued = inQueue(e.id);
|
||||
|
|
@ -648,7 +602,6 @@
|
|||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
|
||||
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||
favoriteItem(e),
|
||||
shareItem(e),
|
||||
!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: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
|
||||
|
|
@ -672,7 +625,6 @@
|
|||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
|
||||
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
|
||||
favoriteItem(e),
|
||||
shareItem(e),
|
||||
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)},
|
||||
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
|
||||
|
|
@ -739,7 +691,6 @@
|
|||
...dlSettingsItems(),
|
||||
]),
|
||||
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},
|
||||
startPageItem(),
|
||||
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
|
||||
|
|
@ -2882,15 +2833,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
return true;
|
||||
};
|
||||
|
||||
// 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) {
|
||||
const f = feeds().find(x => x.id === Number(feedId));
|
||||
if (!f) return false;
|
||||
|
|
@ -4386,8 +4328,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.'));
|
||||
return;
|
||||
}
|
||||
const tr = trOf(id);
|
||||
trFill(tr, d, !!fresh);
|
||||
if (live && sheet) {
|
||||
const fav = sheet.querySelector('.nui-pod-fav');
|
||||
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
|
||||
|
|
@ -4398,23 +4338,10 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
if (chapBox) chapterBox(chapBox, chapters, id);
|
||||
const tabs = el('div', 'nui-pod-subtabs');
|
||||
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 => {
|
||||
current = which;
|
||||
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
|
||||
body.textContent = '';
|
||||
if (which === 'article') {
|
||||
articlePanel(body, id, seek);
|
||||
} else if (which === 'chapters') {
|
||||
if (which === 'chapters') {
|
||||
const ol = el('ol', 'nui-pod-chapters');
|
||||
chapters.forEach((c, i) => {
|
||||
const li = el('li', 'nui-pod-chapter');
|
||||
|
|
@ -4445,7 +4372,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
}
|
||||
body.append(notes);
|
||||
}
|
||||
sig = sigOf();
|
||||
};
|
||||
const tab = (which, label) => {
|
||||
const b = el('button', 'nui-pod-subtab', label);
|
||||
|
|
@ -4454,33 +4380,11 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
b.addEventListener('click', () => show(which));
|
||||
tabs.append(b);
|
||||
};
|
||||
// The Artikel tab comes first (when there is one); Transkript for every episode with sound
|
||||
// (not YouTube: the server never fetches its sound), with a button to make one when it is missing.
|
||||
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);
|
||||
if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`);
|
||||
tab('notes', 'Shownotes');
|
||||
if (d.has_transcript) tab('transcript', 'Transkript');
|
||||
box.append(tabs, body);
|
||||
draw('');
|
||||
if (epScreen && epScreen.e.id === id) episodeScreenParts();
|
||||
trSchedule();
|
||||
show(chapters.length && !chapBox ? 'chapters' : 'notes');
|
||||
}
|
||||
|
||||
// The chapters in the full player: which one plays, previous / next (like AntennaPod's
|
||||
|
|
@ -4551,8 +4455,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
|
||||
|
||||
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 …'));
|
||||
let t = transcripts.get(id);
|
||||
if (!t) {
|
||||
|
|
@ -4566,7 +4468,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
}
|
||||
}
|
||||
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');
|
||||
for (const seg of t.segments || []) {
|
||||
const p = el('p', 'nui-pod-seg');
|
||||
|
|
@ -4594,493 +4495,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
}
|
||||
}
|
||||
|
||||
// ---- smart transcription (diora-web docs/transcription.md) ------------------------------
|
||||
// The server transcribes (a job per episode, polled every 5 s while it is on screen); the article
|
||||
// is written on this device with the user's own key (DioraPodcastArticle). One state per episode
|
||||
// feeds the Transkript tab, the Artikel tab and the "✦ Artikel" button.
|
||||
|
||||
const trStates = new Map(); // episode id -> {job, writing, has, origin, article}
|
||||
const trBoxes = new Set(); // the details boxes on screen (showDetails)
|
||||
const trArticles = new Map(); // feed item id -> its item (the article's text)
|
||||
let trTimer = null;
|
||||
|
||||
async function trFetch(method, url, body) {
|
||||
const headers = {'Content-Type': 'application/json'};
|
||||
if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken();
|
||||
const r = await fetch(url, {method, headers, cache: 'no-store', body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
let data = null;
|
||||
try { data = await r.json(); } catch (e) {}
|
||||
return {ok: r.ok, status: r.status, data};
|
||||
}
|
||||
|
||||
function trFill(t, d, fresh) {
|
||||
if (fresh || !t.has) t.has = !!d.has_transcript;
|
||||
if (fresh || !t.origin) t.origin = d.transcript_origin || null;
|
||||
if (fresh || t.article == null) t.article = d.article_item_id || null;
|
||||
if ((fresh || !t.job) && 'transcription' in d) t.job = d.transcription || null;
|
||||
}
|
||||
|
||||
function trOf(id) {
|
||||
let t = trStates.get(id);
|
||||
if (!t) {
|
||||
t = {job: null, writing: null, has: false, origin: null, article: null, polling: false};
|
||||
trStates.set(id, t);
|
||||
const d = storedDetails(id);
|
||||
if (d) trFill(t, d, true);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
const trActive = j => !!j && (j.state === 'queued' || j.state === 'downloading' || j.state === 'transcribing');
|
||||
|
||||
// What is going on with the episode right now (a transcription on the server, or the article
|
||||
// being written here), as the line the buttons show; null when nothing.
|
||||
function trStatus(id) {
|
||||
const t = trOf(id);
|
||||
if (t.writing) return {text: t.writing.text, kind: 'writing'};
|
||||
const j = t.job;
|
||||
if (!trActive(j)) return null;
|
||||
const text = j.state === 'queued' ? (j.error ? 'Wartet: ' + j.error : 'Wartet …')
|
||||
: j.state === 'downloading' ? 'Lade Folge …'
|
||||
: j.chunks_total ? `Transkribiere ${j.chunks_done || 0}/${j.chunks_total} …` : 'Transkribiere …';
|
||||
return {text, kind: 'job'};
|
||||
}
|
||||
|
||||
// Changes only when what a view shows changes.
|
||||
function trSig(id) {
|
||||
const t = trOf(id);
|
||||
const st = trStatus(id);
|
||||
return (st ? st.text : '') + '|' + (t.job && t.job.state === 'error' ? t.job.error || 'x' : '');
|
||||
}
|
||||
|
||||
const trVisible = id => (epScreen && epScreen.e.id === id && !!notesSheet && notesSheet.isConnected) ||
|
||||
[...trBoxes].some(b => b.isConnected && b.dataset.episode === String(id));
|
||||
|
||||
function trChanged(id, select) {
|
||||
for (const b of [...trBoxes]) {
|
||||
if (!b.isConnected) { trBoxes.delete(b); continue; }
|
||||
if (b.dataset.episode === String(id) && b.trUpdate) b.trUpdate(select);
|
||||
}
|
||||
if (epScreen && epScreen.e.id === id) episodeScreenParts();
|
||||
trSchedule();
|
||||
}
|
||||
|
||||
// Poll the jobs on screen every five seconds; nothing runs when none is shown.
|
||||
function trSchedule() {
|
||||
const ids = [...trStates].filter(([id, t]) => trActive(t.job) && trVisible(id)).map(([id]) => id);
|
||||
if (ids.length && !trTimer) {
|
||||
trTimer = setInterval(trTick, 5000);
|
||||
trTick();
|
||||
} else if (!ids.length && trTimer) {
|
||||
clearInterval(trTimer);
|
||||
trTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function trTick() {
|
||||
if (document.hidden) return;
|
||||
for (const [id, t] of [...trStates]) {
|
||||
if (!trActive(t.job) || !trVisible(id) || t.polling) continue;
|
||||
t.polling = true;
|
||||
try {
|
||||
const r = await trFetch('GET', `/podcasts/episodes/${id}/transcribe/`);
|
||||
if (r.ok && r.data) await trApply(id, r.data);
|
||||
} catch (e) {} finally { t.polling = false; }
|
||||
}
|
||||
trSchedule();
|
||||
}
|
||||
|
||||
async function trApply(id, job) {
|
||||
const t = trOf(id);
|
||||
if (!job || job.state === 'none') { t.job = null; trChanged(id); return; }
|
||||
t.job = job;
|
||||
if (job.state !== 'done') { trChanged(id); return; }
|
||||
const want = !!job.want_article;
|
||||
t.job = null;
|
||||
t.has = true;
|
||||
transcripts.delete(id);
|
||||
await trFresh(id);
|
||||
trChanged(id);
|
||||
// diora is open: the article is written right away (when this device has a key; else it waits).
|
||||
if (want && !t.article && !t.writing) writeArticle(id, {claim: true});
|
||||
}
|
||||
|
||||
async function trFresh(id) {
|
||||
const d = await loadDetails(id);
|
||||
if (d) trFill(trOf(id), d, true);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function startTranscription(id, article) {
|
||||
const t = trOf(id);
|
||||
if (!navigator.onLine) { toast('Das braucht eine Verbindung'); return; }
|
||||
let r;
|
||||
try { r = await trFetch('POST', `/podcasts/episodes/${id}/transcribe/`, {article: !!article}); } catch (e) { r = {ok: false, status: 0}; }
|
||||
if (!r.ok) {
|
||||
const err = r.data && r.data.error;
|
||||
const msg = err === 'youtube' ? 'YouTube-Folgen lassen sich nicht transkribieren.'
|
||||
: err === 'too large' ? 'Die Folge ist zu groß zum Transkribieren (über 800 MB).'
|
||||
: r.status ? `Das Transkribieren ließ sich nicht starten (${r.status}).` : 'Keine Verbindung.';
|
||||
t.job = {state: 'error', error: msg, want_article: !!article};
|
||||
trChanged(id);
|
||||
return;
|
||||
}
|
||||
const job = r.data || {};
|
||||
if (job.state === 'done') {
|
||||
t.job = null;
|
||||
t.has = true;
|
||||
await trFresh(id);
|
||||
trChanged(id);
|
||||
if (article) writeArticle(id);
|
||||
return;
|
||||
}
|
||||
t.job = Object.assign({state: 'queued', want_article: !!article}, job);
|
||||
if (article && window.DioraPodcastArticle) DioraPodcastArticle.watch(true);
|
||||
trChanged(id);
|
||||
}
|
||||
|
||||
async function cancelTranscription(id) {
|
||||
const t = trOf(id);
|
||||
try { await trFetch('DELETE', `/podcasts/episodes/${id}/transcribe/`); } catch (e) {}
|
||||
t.job = null;
|
||||
trChanged(id);
|
||||
}
|
||||
|
||||
// The key first: without one the settings open (write() would wait for ever behind the sheet).
|
||||
async function trKeyReady() {
|
||||
const AI = window.DioraAI;
|
||||
if (!AI || !AI.available()) { toast('Die KI-Funktionen gibt es hier nicht'); return false; }
|
||||
let has = false;
|
||||
try { has = await AI.hasKey(); } catch (e) {}
|
||||
if (!has) {
|
||||
toast('Für den Artikel braucht diora deinen KI-Schlüssel');
|
||||
AI.settings();
|
||||
}
|
||||
return has;
|
||||
}
|
||||
|
||||
async function articleAction(id) {
|
||||
const st = trStatus(id);
|
||||
if (st) { toast(st.text); return; }
|
||||
if (!(await trKeyReady())) return;
|
||||
if (trOf(id).has) writeArticle(id);
|
||||
else startTranscription(id, true);
|
||||
}
|
||||
|
||||
// Write the article here and save it. `claim`: a wish of the server (reserve it first, so that the
|
||||
// phone and the desktop don't both write it) and no questions when there is no key.
|
||||
async function writeArticle(id, opts) {
|
||||
const claim = !!(opts && opts.claim);
|
||||
const t = trOf(id);
|
||||
if (t.writing) return;
|
||||
const AI = window.DioraAI;
|
||||
const PA = window.DioraPodcastArticle;
|
||||
if (!AI || !AI.available() || !PA) { if (!claim) toast('Artikel schreiben geht hier nicht'); return; }
|
||||
if (claim) {
|
||||
let has = false;
|
||||
try { has = await AI.hasKey(); } catch (e) {}
|
||||
if (!has) return;
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
t.writing = {text: 'Lade Transkript …', ctrl};
|
||||
trChanged(id);
|
||||
let claimed = false;
|
||||
try {
|
||||
if (claim) {
|
||||
const c = await PA.claim(id);
|
||||
if (!c.ok) { t.writing = null; trChanged(id); return; } // another device has it
|
||||
claimed = true;
|
||||
}
|
||||
let last = 0;
|
||||
const saved = await PA.write(id, {
|
||||
signal: ctrl.signal,
|
||||
onState: s => {
|
||||
if (!t.writing) return;
|
||||
t.writing.text = s.text;
|
||||
const now = Date.now();
|
||||
if (s.phase !== 'writing' || now - last > 1000) { last = now; trChanged(id); }
|
||||
},
|
||||
});
|
||||
if (t.article) trArticles.delete(t.article);
|
||||
t.article = saved.item_id;
|
||||
t.writing = null;
|
||||
await trFresh(id);
|
||||
if (claim && PA.notifyDone) PA.notifyDone(saved.title, id); else toast('Artikel fertig');
|
||||
trChanged(id, 'article');
|
||||
} catch (e) {
|
||||
t.writing = null;
|
||||
if (claimed) { try { await PA.release(id); } catch (e2) {} }
|
||||
trChanged(id);
|
||||
if (ctrl.signal.aborted) toast('Abgebrochen');
|
||||
else if (AI.showError) AI.showError('Artikel schreiben', e);
|
||||
else toast((e && e.message) || 'Der Artikel ließ sich nicht schreiben');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeArticle(id) {
|
||||
const t = trOf(id);
|
||||
const ok = typeof customConfirm === 'function'
|
||||
? await customConfirm('Den Artikel zu dieser Folge entfernen? Er verschwindet auch aus „Podcast-Artikel“ in den Blogs.', {danger: true}) : true;
|
||||
if (!ok) return;
|
||||
let r;
|
||||
try { r = await trFetch('DELETE', `/podcasts/episodes/${id}/article/`); } catch (e) { r = {ok: false}; }
|
||||
if (!r.ok) { toast('Entfernen ging nicht'); return; }
|
||||
if (t.article) trArticles.delete(t.article);
|
||||
t.article = null;
|
||||
await trFresh(id);
|
||||
trChanged(id);
|
||||
toast('Artikel entfernt');
|
||||
}
|
||||
|
||||
// The Transkript tab of an episode without a transcript: start, progress, error.
|
||||
function transcriptPanel(body, id) {
|
||||
const t = trOf(id);
|
||||
body.textContent = '';
|
||||
const st = trStatus(id);
|
||||
const wrap = el('div', 'nui-pod-tr');
|
||||
if (st) {
|
||||
wrap.append(el('div', 'nui-pod-tr-status', st.text));
|
||||
if (st.kind === 'job') {
|
||||
wrap.append(el('div', 'nui-pod-meta', t.job.want_article
|
||||
? 'Das dauert je nach Länge Minuten bis Stunden. Danach schreibt diora den Artikel, wenn diora offen ist.'
|
||||
: 'Das dauert je nach Länge Minuten bis Stunden. Du bekommst eine Benachrichtigung, wenn es fertig ist.'));
|
||||
wrap.append(trButton('Abbrechen', () => cancelTranscription(id)));
|
||||
} else {
|
||||
wrap.append(trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
|
||||
}
|
||||
} else if (t.job && t.job.state === 'error') {
|
||||
wrap.append(el('div', 'nui-pod-tr-error', t.job.error || 'Das Transkribieren ist fehlgeschlagen.'));
|
||||
wrap.append(trButton('Erneut versuchen', () => startTranscription(id, !!t.job.want_article), true));
|
||||
} else {
|
||||
wrap.append(el('div', 'nui-pod-meta', 'Diese Folge hat kein Transkript. Der Server kann es erstellen; das dauert je nach Länge ' +
|
||||
'Minuten bis Stunden, am Ende kommt eine Benachrichtigung.'));
|
||||
wrap.append(trButton('Transkript erstellen', () => startTranscription(id, false), true));
|
||||
}
|
||||
body.append(wrap);
|
||||
}
|
||||
|
||||
function trButton(label, run, primary) {
|
||||
const b = el('button', 'btn nui-pod-trbtn' + (primary ? ' btn-primary' : ''), label);
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', run);
|
||||
return b;
|
||||
}
|
||||
|
||||
// The Artikel tab: the article from Blogs (a feed item), its three actions, taps on "▶ ab 12:34" seek.
|
||||
async function articlePanel(body, id, seek) {
|
||||
const t = trOf(id);
|
||||
const itemId = t.article;
|
||||
body.textContent = '';
|
||||
const bar = el('div', 'nui-pod-trbar');
|
||||
const st = trStatus(id);
|
||||
if (st && st.kind === 'writing') {
|
||||
bar.append(el('span', 'nui-pod-tr-status', st.text), trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
|
||||
} else {
|
||||
if (window.__dioraFeedsOpenItem) {
|
||||
bar.append(trButton('In Blogs öffnen', () => {
|
||||
closeNotes();
|
||||
closePlayer();
|
||||
window.__dioraFeedsOpenItem(itemId);
|
||||
}));
|
||||
}
|
||||
if (window.DioraAI && DioraAI.available() && !st) bar.append(trButton('Neu erstellen', () => rewriteArticle(id)));
|
||||
bar.append(trButton('Entfernen', () => removeArticle(id)));
|
||||
}
|
||||
const text = el('div', 'nui-pod-notes nui-pod-article');
|
||||
body.append(bar, text);
|
||||
let item = trArticles.get(itemId);
|
||||
if (!item) {
|
||||
text.append(el('div', 'nui-pod-meta', 'Artikel wird geladen …'));
|
||||
try {
|
||||
item = (await getJson(`/feeds/items/${itemId}/`)).item;
|
||||
if (item) trArticles.set(itemId, item);
|
||||
} catch (e) {}
|
||||
if (!text.isConnected || t.article !== itemId) return;
|
||||
text.textContent = '';
|
||||
}
|
||||
if (!item) {
|
||||
text.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Der Artikel ließ sich nicht laden.' : 'Der Artikel braucht eine Verbindung.'));
|
||||
return;
|
||||
}
|
||||
// The server cleaned the HTML (feeds/fetch.py clean_html), as for every feed item.
|
||||
if (item.content) text.innerHTML = item.content;
|
||||
else text.append(el('p', null, item.summary || ''));
|
||||
text.querySelectorAll('a[href]:not([href^="#t="])').forEach(a => a.setAttribute('target', '_blank'));
|
||||
text.addEventListener('click', ev => {
|
||||
const a = ev.target.closest('a[href^="#t="]');
|
||||
if (!a) return;
|
||||
ev.preventDefault();
|
||||
const s = parseInt(a.getAttribute('href').slice(3), 10);
|
||||
if (s >= 0) seek(s);
|
||||
});
|
||||
}
|
||||
|
||||
// "Neu erstellen" replaces the article: ask first.
|
||||
async function rewriteArticle(id) {
|
||||
const ok = typeof customConfirm === 'function'
|
||||
? await customConfirm('Den Artikel neu schreiben? Der jetzige wird ersetzt.') : true;
|
||||
if (ok) articleAction(id);
|
||||
}
|
||||
|
||||
// The "✦ Artikel" button of the episode screen, or null where it isn't offered.
|
||||
function trArticleButton(e) {
|
||||
const id = e.id;
|
||||
if (!(id > 0)) return null;
|
||||
const t = trOf(id);
|
||||
const d = storedDetails(id) || {};
|
||||
const url = (e.raw && (e.raw.audio_url || e.raw.episode__audio_url)) || d.audio_url || '';
|
||||
if (e.yt || !url || isYouTube(url)) return null;
|
||||
const st = trStatus(id);
|
||||
if (st) return {label: st.text, run: () => toast(st.text), cls: 'on'};
|
||||
if (t.article) return {label: 'Artikel', run: () => { if (epScreen && epScreen.box && epScreen.box.trUpdate) epScreen.box.trUpdate('article'); }, cls: 'on'};
|
||||
if (!(window.DioraAI && DioraAI.available())) return null;
|
||||
return {label: t.has ? 'Artikel erstellen' : 'Transkribieren und Artikel erstellen', run: () => articleAction(id)};
|
||||
}
|
||||
|
||||
// ---- settings of the transcription instance (GET/POST /podcasts/transcription/settings/) ----
|
||||
|
||||
const TR_LANGUAGES = [['', 'Automatisch'], ['de', 'Deutsch'], ['en', 'Englisch'], ['fr', 'Französisch'], ['es', 'Spanisch'],
|
||||
['it', 'Italienisch'], ['nl', 'Niederländisch'], ['pt', 'Portugiesisch'], ['pl', 'Polnisch'], ['ru', 'Russisch'],
|
||||
['tr', 'Türkisch'], ['uk', 'Ukrainisch'], ['ja', 'Japanisch'], ['zh', 'Chinesisch']];
|
||||
let trSheet = null;
|
||||
|
||||
function closeTrSettings() {
|
||||
if (!trSheet) return false;
|
||||
trSheet.remove();
|
||||
trSheet = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function transcriptionSettings() {
|
||||
closeTrSettings();
|
||||
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-trset', 'Schließen', closeTrSettings);
|
||||
trSheet = made;
|
||||
const body = el('div', 'nui-pod-trform');
|
||||
panel.append(top, el('div', 'nui-pod-player-title', 'Transkription'), body);
|
||||
document.body.append(made);
|
||||
body.append(el('div', 'nui-pod-meta', 'Einstellungen werden geladen …'));
|
||||
let s;
|
||||
try {
|
||||
const r = await trFetch('GET', '/podcasts/transcription/settings/');
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
s = r.data;
|
||||
} catch (e) {
|
||||
body.textContent = '';
|
||||
body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Die Einstellungen ließen sich nicht laden.' : 'Die Einstellungen brauchen eine Verbindung.'));
|
||||
return;
|
||||
}
|
||||
if (trSheet !== made) return;
|
||||
drawTranscriptionSettings(body, s, made);
|
||||
}
|
||||
|
||||
function drawTranscriptionSettings(body, s, made) {
|
||||
body.textContent = '';
|
||||
const field = (label, input) => {
|
||||
const l = el('label', 'nui-pod-trfield');
|
||||
l.append(el('span', null, label), input);
|
||||
return l;
|
||||
};
|
||||
const text = (type, value, placeholder) => {
|
||||
const i = el('input', 'search-input');
|
||||
i.type = type;
|
||||
i.value = value == null ? '' : value;
|
||||
if (placeholder) i.placeholder = placeholder;
|
||||
i.setAttribute('autocomplete', 'off');
|
||||
i.setAttribute('autocapitalize', 'off');
|
||||
i.spellcheck = false;
|
||||
return i;
|
||||
};
|
||||
body.append(el('div', 'nui-pod-meta', 'Der Server schneidet die Folge in Stücke und schickt sie an eine Whisper-Instanz ' +
|
||||
'(OpenAI-kompatibel). Das passiert nur, wenn du es in einer Folge auslöst.'));
|
||||
let engine = s.engine === 'builtin' && s.builtin_available ? 'builtin' : 'custom';
|
||||
const engines = el('div', 'nui-pod-subtabs');
|
||||
const options = [s.builtin_available && ['builtin', 'Eingebaute Instanz'], ['custom', 'Eigene Instanz']].filter(Boolean);
|
||||
const url = text('url', s.url, 'https://whisper.example.org/v1');
|
||||
const token = text('password', '', s.has_token ? 'gespeichert (leer lassen = behalten)' : 'optional');
|
||||
token.setAttribute('autocomplete', 'new-password');
|
||||
const urlField = field('Adresse', url);
|
||||
const tokenField = field('Token', token);
|
||||
const sync = () => {
|
||||
engines.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.engine === engine));
|
||||
urlField.hidden = tokenField.hidden = engine !== 'custom';
|
||||
};
|
||||
for (const [value, label] of options) {
|
||||
const b = el('button', 'nui-pod-subtab', label);
|
||||
b.type = 'button';
|
||||
b.dataset.engine = value;
|
||||
b.addEventListener('click', () => { engine = value; sync(); });
|
||||
engines.append(b);
|
||||
}
|
||||
body.append(engines, urlField, tokenField);
|
||||
const model = text('text', s.model, 'z. B. large-v3-turbo');
|
||||
const lang = el('select', 'search-input');
|
||||
const langs = TR_LANGUAGES.some(l => l[0] === (s.language || '')) ? TR_LANGUAGES : TR_LANGUAGES.concat([[s.language, s.language]]);
|
||||
for (const [v, l] of langs) {
|
||||
const o = el('option', null, l);
|
||||
o.value = v;
|
||||
lang.append(o);
|
||||
}
|
||||
lang.value = s.language || '';
|
||||
const par = text('number', s.parallel || 3);
|
||||
par.min = '1';
|
||||
par.max = '8';
|
||||
par.inputMode = 'numeric';
|
||||
body.append(field('Modell', model), field('Sprache', lang), field('Parallel (1 bis 8 Stücke gleichzeitig)', par));
|
||||
const status = el('div', 'nui-pod-tr-status');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
const say = (msg, bad) => { status.textContent = msg; status.classList.toggle('nui-pod-tr-error', !!bad); };
|
||||
|
||||
// Save; the token is sent only when typed (empty keeps it) or removed on purpose.
|
||||
const save = async removeToken => {
|
||||
if (engine === 'custom' && !url.value.trim()) { say('Bitte eine Adresse eintragen.', true); return null; }
|
||||
const post = {
|
||||
engine, url: url.value.trim(), model: model.value.trim(), language: lang.value,
|
||||
parallel: Math.max(1, Math.min(8, parseInt(par.value, 10) || 3)),
|
||||
};
|
||||
if (removeToken) post.token = '';
|
||||
else if (token.value) post.token = token.value;
|
||||
const r = await trFetch('POST', '/podcasts/transcription/settings/', post);
|
||||
if (!r.ok) { say((r.data && r.data.error) || `Speichern ging nicht (${r.status}).`, true); return null; }
|
||||
let next = r.data && 'engine' in r.data ? r.data : null;
|
||||
if (!next) {
|
||||
const g = await trFetch('GET', '/podcasts/transcription/settings/');
|
||||
next = g.ok ? g.data : Object.assign({}, s, post, {has_token: !!(s.has_token && !removeToken) || !!post.token});
|
||||
}
|
||||
return next;
|
||||
};
|
||||
const run = fn => async () => {
|
||||
try { await fn(); } catch (e) { say(navigator.onLine ? 'Das ging nicht.' : 'Keine Verbindung.', true); }
|
||||
};
|
||||
const buttons = el('div', 'nui-pod-trbar');
|
||||
buttons.append(
|
||||
trButton('Speichern', run(async () => {
|
||||
const next = await save(false);
|
||||
if (next && trSheet === made) { toast('Gespeichert'); drawTranscriptionSettings(body, next, made); }
|
||||
}), true),
|
||||
trButton('Verbindung testen', run(async () => {
|
||||
say('Speichere und teste …');
|
||||
const next = await save(false);
|
||||
if (!next) return;
|
||||
const r = await trFetch('POST', '/podcasts/transcription/test/', {});
|
||||
if (trSheet !== made) return;
|
||||
drawTranscriptionSettings(body, next, made);
|
||||
const out = body.querySelector('.nui-pod-tr-status');
|
||||
const ok = r.ok && r.data && r.data.ok;
|
||||
out.textContent = ok ? `Verbindung steht${r.data.model ? ' (Modell ' + r.data.model + ')' : ''}.`
|
||||
: 'Verbindung fehlgeschlagen: ' + ((r.data && r.data.error) || `Fehler ${r.status}`);
|
||||
out.classList.toggle('nui-pod-tr-error', !ok);
|
||||
})));
|
||||
if (s.has_token) {
|
||||
buttons.append(trButton('Token entfernen', run(async () => {
|
||||
const next = await save(true);
|
||||
if (next && trSheet === made) { toast('Token entfernt'); drawTranscriptionSettings(body, next, made); }
|
||||
})));
|
||||
}
|
||||
body.append(buttons, status);
|
||||
if (s.has_token) body.append(el('div', 'nui-pod-meta', 'Token: gespeichert'));
|
||||
sync();
|
||||
}
|
||||
|
||||
// ---- the episode screen: a tap on a row opens it ----------------------------------------
|
||||
// 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.
|
||||
|
|
@ -5138,7 +4552,7 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
}).catch(() => {});
|
||||
}
|
||||
document.body.append(notesSheet);
|
||||
epScreen = {e, ctx, actions, playBtn, box, facts: info.querySelector('.nui-pod-epfacts')};
|
||||
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
|
||||
episodeScreenParts();
|
||||
showDetails(box, e.id, false, seconds => playAt(e, seconds));
|
||||
}
|
||||
|
|
@ -5199,9 +4613,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
if (N.podcastDiscard) {
|
||||
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;
|
||||
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
|
||||
fav ? 'on' : '');
|
||||
|
|
@ -6417,7 +5828,6 @@ neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wi
|
|||
window.__dioraBack = function () {
|
||||
if (closeStats()) return true;
|
||||
if (closeDownloads()) return true;
|
||||
if (closeTrSettings()) return true;
|
||||
if (closeNotes()) return true;
|
||||
if (closePlayer()) return true;
|
||||
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copied from diora-android 6a0a362 2026-11-09: the podcast parts of web-native/static/native.css
|
||||
/* Copied from diora-android 7f37d4e 2026-10-25: the podcast parts of web-native/static/native.css
|
||||
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
|
||||
The web's own additions are in podcasts-web.css. */
|
||||
|
||||
|
|
@ -500,9 +500,7 @@ body.eink .nui-pod-chip.on strong,
|
|||
body.eink .nui-pod-sleep.on { color: #000 !important; }
|
||||
|
||||
/* podcast chapters and shownotes */
|
||||
/* 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-notesbox { 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-subtab {
|
||||
min-height: 36px;
|
||||
|
|
@ -550,21 +548,6 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
|
|||
.nui-pod-epact-icon { font-size: 20px; line-height: 22px; }
|
||||
.nui-pod-epact.on .nui-pod-epact-icon { color: var(--accent); }
|
||||
.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-danger { color: #000; }
|
||||
body.eink .nui-pod-chapter.current,
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@
|
|||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
</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">
|
||||
Noch kein Konto? <a href="{% url 'register' %}">Registrieren</a>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden — diora</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
font: 15px/1.5 system-ui, sans-serif; background: #111; color: #ccc; }
|
||||
@media (prefers-color-scheme: light) { body { background: #fafafa; color: #444; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p id="status">Bücher werden entsperrt …</p>
|
||||
{{ config|json_script:"oidc-key-config" }}
|
||||
<script>
|
||||
// The book key with Authelia's help (accounts/oidc_views.py oidc_key, docs/accounts.md).
|
||||
// Every way out ends on `next`; whatever fails here, the app asks for the key itself
|
||||
// (static/js/app/33-unlock.js).
|
||||
(async function () {
|
||||
const C = JSON.parse(document.getElementById('oidc-key-config').textContent);
|
||||
const SS = 'diora_oidc_key_flow';
|
||||
const KEY_NAME = 'diora_enc_key_' + C.user_id;
|
||||
const status = document.getElementById('status');
|
||||
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
const unb64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
|
||||
const b64url = (buf) => b64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const hexToBytes = (h) => Uint8Array.from(h.match(/../g) || [], x => parseInt(x, 16));
|
||||
const csrf = () => (document.cookie.split('; ').find(r => r.startsWith('csrftoken=')) || '').split('=')[1] || '';
|
||||
const done = (to) => location.replace(to || C.next || '/');
|
||||
|
||||
async function wrapKey(secret, sub) {
|
||||
const hk = await crypto.subtle.importKey('raw', unb64(secret), 'HKDF', false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey(
|
||||
{name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode(sub), info: new TextEncoder().encode('diora authelia key v1')},
|
||||
hk, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
// Is this the key of the books? Tried on one book's metadata (none: nothing to check).
|
||||
async function fits(raw) {
|
||||
try {
|
||||
const books = await (await fetch('/books/', {credentials: 'same-origin'})).json();
|
||||
const b = Array.isArray(books) ? books.find(x => x && x.meta_ct && x.meta_iv) : null;
|
||||
if (!b) return true;
|
||||
const k = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['decrypt']);
|
||||
await crypto.subtle.decrypt({name: 'AES-GCM', iv: hexToBytes(b.meta_iv)}, k, unb64(b.meta_ct));
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const isCallback = location.pathname.endsWith('/callback/');
|
||||
|
||||
if (!isCallback) {
|
||||
// Also a device that has its key goes once round: it leaves the key (again) wrapped with
|
||||
// the current claim, so a changed claim at Authelia heals with the next login.
|
||||
const flow = {
|
||||
state: b64url(crypto.getRandomValues(new Uint8Array(24))),
|
||||
verifier: b64url(crypto.getRandomValues(new Uint8Array(48))),
|
||||
next: C.next,
|
||||
};
|
||||
sessionStorage.setItem(SS, JSON.stringify(flow));
|
||||
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(flow.verifier)));
|
||||
const q = new URLSearchParams({
|
||||
response_type: 'code', client_id: C.client_id, redirect_uri: C.redirect_uri,
|
||||
scope: 'openid profile diora_key', state: flow.state, prompt: 'none',
|
||||
code_challenge: challenge, code_challenge_method: 'S256',
|
||||
});
|
||||
location.replace(C.authorize + '?' + q);
|
||||
return;
|
||||
}
|
||||
|
||||
let flow = null;
|
||||
try { flow = JSON.parse(sessionStorage.getItem(SS) || 'null'); } catch (e) {}
|
||||
sessionStorage.removeItem(SS);
|
||||
const next = flow && flow.next;
|
||||
try {
|
||||
if (!flow || params.get('state') !== flow.state || !params.get('code')) return done(next);
|
||||
const tok = await (await fetch(C.token, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code', code: params.get('code'), redirect_uri: C.redirect_uri,
|
||||
client_id: C.client_id, code_verifier: flow.verifier,
|
||||
}),
|
||||
})).json();
|
||||
if (!tok.access_token) return done(next);
|
||||
const info = await (await fetch(C.userinfo, {headers: {Authorization: 'Bearer ' + tok.access_token}})).json();
|
||||
// Only the Authelia account linked to this diora account counts.
|
||||
if (!info.sub || !C.subs.includes(info.sub) || !info.diora_key) return done(next);
|
||||
const wrap = await wrapKey(info.diora_key, info.sub);
|
||||
|
||||
let have = null;
|
||||
try { have = localStorage.getItem(KEY_NAME); } catch (e) {}
|
||||
if (have) {
|
||||
// This device has the key: leave it with the server, wrapped — only the right one (an
|
||||
// old browser may hold a stray key of its own).
|
||||
if (!(await fits(unb64(have)))) return done(next);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrap, unb64(have));
|
||||
await fetch('/accounts/vault/authelia-key/', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
|
||||
body: JSON.stringify({ct: b64(ct), iv: b64(iv)}),
|
||||
});
|
||||
return done(next);
|
||||
}
|
||||
const v = await (await fetch('/accounts/vault/authelia-key/', {credentials: 'same-origin'})).json();
|
||||
if (!v.ct) return done(next);
|
||||
const raw = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: unb64(v.iv)}, wrap, unb64(v.ct)));
|
||||
if (raw.length === 32 && await fits(raw)) localStorage.setItem(KEY_NAME, b64(raw));
|
||||
} catch (e) {
|
||||
status.textContent = 'Entsperren über Authelia ging nicht – du kannst es gleich mit Code oder Passwort tun.';
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
done(next);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -94,25 +94,6 @@
|
|||
</form>
|
||||
</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;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn">Abmelden</button>
|
||||
|
|
@ -287,8 +268,10 @@ async function _getOrCreateEncKey() {
|
|||
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
|
||||
} catch (e) {}
|
||||
}
|
||||
// Never a new key here: it would lock the existing books away (static/js/app/33-unlock.js).
|
||||
throw new Error('Bücher sind auf diesem Gerät noch nicht entsperrt – öffne zuerst die Bibliothek');
|
||||
const key = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||
const raw = await crypto.subtle.exportKey('raw', key);
|
||||
localStorage.setItem(storageKey, _bytesToBase64(raw));
|
||||
return key;
|
||||
}
|
||||
|
||||
document.getElementById('pw-change-form')?.addEventListener('submit', function(e) {
|
||||
|
|
|
|||
|
|
@ -371,7 +371,6 @@
|
|||
<script src="/static/js/app/29-m3u.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/33-unlock.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/44-books.js"></script>
|
||||
|
|
@ -387,9 +386,7 @@
|
|||
<script src="/static/js/app/58-pdf.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/63-pages.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/68-bookmarks.js"></script>
|
||||
<script src="/static/js/app/70-search.js"></script>
|
||||
|
|
@ -400,8 +397,7 @@
|
|||
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
||||
<script src="/static/js/app/99-init.js"></script>
|
||||
{% if user.is_authenticated %}{# The KI features (docs/ai.md): the user's own key, calls from the device. #}
|
||||
<script type="module" src="/static/js/ai.js"></script>
|
||||
<script type="module" src="/static/js/podcast-article.js"></script>{% endif %}
|
||||
<script type="module" src="/static/js/ai.js"></script>{% endif %}
|
||||
<script src="/static/js/podcasts-web.js"></script>
|
||||
<script src="/static/js/home.js"></script>
|
||||
<script src="/static/js/feeds.js"></script>
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
// Tests of static/js/podcast-article.js under Node: node --test tools/tests/
|
||||
// No network, no LLM: fetch and DioraAI are stubbed.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as pa from '../../static/js/podcast-article.js';
|
||||
|
||||
test('transcriptToText groups 30 seconds and splits on speaker change', () => {
|
||||
const segs = [
|
||||
{start: 0, end: 5, speaker: '', text: 'Hallo zusammen.'},
|
||||
{start: 20, end: 29, speaker: '', text: 'Heute geht es um X.'},
|
||||
{start: 31, end: 40, speaker: '', text: 'Zweite Gruppe.'},
|
||||
{start: 3725, end: 3730, speaker: '', text: 'Spät.'},
|
||||
{start: 3726, end: 3730, speaker: '', text: ' '},
|
||||
];
|
||||
assert.equal(pa.transcriptToText(segs),
|
||||
'[00:00] Hallo zusammen. Heute geht es um X.\n[00:31] Zweite Gruppe.\n[62:05] Spät.');
|
||||
const sp = [
|
||||
{start: 0, speaker: 'Anna', text: 'Eins.'},
|
||||
{start: 4, speaker: 'Anna', text: 'Zwei.'},
|
||||
{start: 8, speaker: 'Bernd', text: 'Drei.'},
|
||||
];
|
||||
assert.equal(pa.transcriptToText(sp), '[00:00] Anna: Eins. Zwei.\n[00:08] Bernd: Drei.');
|
||||
assert.equal(pa.transcriptToText([]), '');
|
||||
});
|
||||
|
||||
test('clock and mmss', () => {
|
||||
assert.equal(pa.mmss(754), '12:34');
|
||||
assert.equal(pa.clock(754), '12:34');
|
||||
assert.equal(pa.clock(3725), '1:02:05');
|
||||
});
|
||||
|
||||
test('token estimate and the limit message', () => {
|
||||
assert.equal(pa.estimateTokens('a'.repeat(400)), 100);
|
||||
const big = {segments: Array.from({length: 20000}, (_, i) => ({start: i * 31, speaker: '', text: 'x'.repeat(30)}))};
|
||||
assert.throws(() => pa.buildPrompt({title: 'T'}, big), e => e.diora && /zu lang/.test(e.message));
|
||||
const ok = pa.buildPrompt({title: 'T', feed: {title: 'P'}, description: '<p>Notes & 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);
|
||||
});
|
||||
175
tui/README.md
175
tui/README.md
|
|
@ -11,10 +11,9 @@ Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
|
|||
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
|
||||
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.
|
||||
Die Lesestelle folgt dem Vertrag aus `docs/progress-sync.md`: **die zuletzt gelesene Stelle
|
||||
gewinnt** (auf welchem Gerät auch immer), die weiteste bleibt als Hinweis erhalten. Die
|
||||
Regeln stehen in `progress.py` und sind dieselben wie auf dem Server und in den Apps.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -33,12 +32,17 @@ Tastenkürzel:
|
|||
|
||||
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
|
||||
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel
|
||||
- `w` — zur weitesten Stelle des Buchs springen (auf Wunsch; beim Öffnen weist ein Hinweis darauf hin)
|
||||
- `v` — Text markieren (siehe unten), `b` — Lesezeichen setzen, `a` — Liste der Lesezeichen,
|
||||
Markierungen und Notizen
|
||||
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q`
|
||||
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)
|
||||
- Bibliothek: `Enter` öffnet das markierte Buch, `c` das Buch der „Weiterlesen“-Karte, `1`–`4`
|
||||
Status (Alle · Angefangen · Ungelesen · Gelesen), `o` Ordner, `/` Suche, `s` Sortierung,
|
||||
`m` Bücher ⇄ Zeitschriften, `Escape` setzt Suche und Filter zurück. Chips und Karte lassen
|
||||
sich auch anklicken.
|
||||
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek)
|
||||
|
||||
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
|
||||
|
|
@ -47,63 +51,102 @@ volle Terminalbreite bei breiten Fenstern).
|
|||
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
|
||||
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.
|
||||
## Die Bibliothek
|
||||
|
||||
## Bücher + Fortschritt vom Server holen (`diora-tui sync`)
|
||||
Wie die Bibliothek im Web (`docs/library.md`), nur im Terminal:
|
||||
|
||||
Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe
|
||||
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.ä.):
|
||||
- **Weiterlesen-Karte** oben (Titel, Autor, Balken, „Seite 132 von 186“, wann und auf welchem
|
||||
Gerät zuletzt gelesen) und darunter die Bücher, die davor gelesen wurden. Das Buch der Karte
|
||||
steht nicht noch einmal in der Liste. Bei Suche und Filtern entfällt die Karte.
|
||||
- **Chips:** Alle · Angefangen · Ungelesen · Gelesen (mit Anzahl), dann die Ordner in der
|
||||
Reihenfolge, die im Web eingestellt ist (verschlüsselte Bibliothekseinstellungen), und
|
||||
„Ohne Ordner“. Ein Buch kann in mehreren Ordnern stehen.
|
||||
- **Suche** in Titel und Autor, ohne Beachtung von Groß-/Kleinschreibung und Akzenten.
|
||||
- **Sortierung:** Zuletzt · Titel · Autor · Neu hinzugefügt · Kürzeste/Längste zuerst (Zahlen
|
||||
nach Wert: „Band 2“ vor „Band 10“). Sie bleibt gespeichert.
|
||||
- **Länge:** die Seitenzahl aus den Metadaten (`pages`); fehlt sie, rechnet die TUI sie für
|
||||
schon geladene Bücher einmal aus dem Text aus (1800 Zeichen je Seite, wie der Web-Reader), wenn
|
||||
nach Länge sortiert wird („Länge wird ermittelt …“). Nicht geladene Bücher ohne Länge stehen am
|
||||
Ende. Zurück auf den Server schreibt die TUI die Länge nicht; das macht der Web-Reader.
|
||||
- **Bücher ⇄ Zeitschriften** (`m`): Zeitschriften sind, was in den Metadaten so markiert ist
|
||||
oder im Ordner „Zeitschriften“/„Magazine“ liegt. Die Heft-Ansicht mit Artikeln gibt es in der
|
||||
TUI nicht; ein Heft ist ein Buch.
|
||||
- ☁ vor dem Titel: auf dem Server, noch nicht geladen. ✓: gelesen.
|
||||
|
||||
Nicht in der TUI: Serien-Stapel, Ausgaben (mehrere Fassungen eines Werks stehen einzeln),
|
||||
Cover, Ordner anlegen/zuweisen, Eingang aus der Cloud.
|
||||
|
||||
## Markierungen, Notizen, Lesezeichen
|
||||
|
||||
Dieselben Daten wie im Web-Reader (verschlüsselt mit dem Buchschlüssel, je Buch und Art eine Liste
|
||||
`/books/<id>/highlights/` und `/bookmarks/`; Format in `annotations.py`). Was im Web, in den Apps
|
||||
oder hier gemacht wird, erscheint überall.
|
||||
|
||||
- **Anzeige:** Markierungen stehen im Text mit der Farbe (Gelb, Grün, Blau, Rot); eine freie Notiz
|
||||
ist unterstrichen. Die Anker des Web-Readers (Block-Nummer plus Zeichenposition im Rohtext des
|
||||
Absatzes) werden auf den angezeigten Text umgerechnet; alte Anker ohne Blocknummer findet die TUI
|
||||
wie das Web über das Zitat. `a` öffnet die Liste (Enter springt dorthin, `e` ändert die Notiz,
|
||||
`d` löscht, Escape schließt).
|
||||
- **Markieren (`v`):** Der Cursor steht auf einem Wort; `h`/`l` wechseln das Wort, `j`/`k` den
|
||||
Absatz. Enter setzt den Anfang, danach `h`/`l`/`j`/`k` zum Ende und Enter: Farbe wählen, oder
|
||||
„Gelb mit Notiz“. `n` legt statt dessen eine Notiz auf das Wort bzw. die Auswahl. Escape bricht
|
||||
ab. Stellen, die sich nicht genau zuordnen lassen (ein Fußnotenmarker mit Leerraum im Absatz),
|
||||
lehnt die TUI ab, statt eine falsche Position zu schreiben.
|
||||
- **Lesezeichen (`b`):** wie im Web das aktuelle Kapitel (Name der Kapiteldatei, neueste zuerst).
|
||||
- **Abgleich:** Der Server ersetzt die Liste als Ganzes und führt nichts zusammen; die Apps
|
||||
schreiben deshalb ihre ganze Liste. Die TUI merkt sich, was sie zuletzt vom Server gesehen hat, und
|
||||
legt vor dem Schreiben ihre Änderungen (neu, gelöscht, geändert, je Eintrag-ID) auf die aktuelle
|
||||
Liste des Servers: was ein anderes Gerät inzwischen markiert hat, geht nicht verloren. Offline
|
||||
bleiben Änderungen vorgemerkt und gehen beim nächsten Abgleich, beim Verlassen des Buchs oder
|
||||
beim Beenden hinaus. Beim Öffnen eines Buchs werden seine Listen geholt.
|
||||
- **Prüfung gegen das Web:** `tools/parity/web_anchors.cjs` lässt den echten `resolveAnchorRange`
|
||||
des Web-Readers die in der TUI erzeugten Anker auflösen (an zwei echten Büchern: alle 81 Anker
|
||||
an der gemeinten Stelle); umgekehrt lassen sich 34 von 34 echten Web-Markierungen anzeigen (eine alte
|
||||
XPath-Notiz über ihr Zitat). Nicht in der TUI: PDF-Markierungen.
|
||||
|
||||
## Anmelden und synchronisieren
|
||||
|
||||
```bash
|
||||
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
|
||||
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern
|
||||
diora-tui login # einmal; fragt Benutzername und Passwort
|
||||
diora-tui # die Oberfläche, synct beim Start und beim Beenden
|
||||
diora-tui sync # nur abgleichen, ohne Oberfläche
|
||||
diora-tui logout # gespeicherte Zugangsdaten löschen
|
||||
```
|
||||
|
||||
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter,
|
||||
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).
|
||||
`login` meldet sich wie die Apps über das Web-Formular an, liest den vorhandenen API-Token
|
||||
von der Einstellungsseite (es wird nie ein neuer erzeugt: das würde die Apps abmelden) und
|
||||
leitet den Buchschlüssel aus dem Passwort ab (PBKDF2, wie „Mit Passwort entsperren“ im Web).
|
||||
Das Passwort wird nicht gespeichert. Passt der abgeleitete Schlüssel nicht (Passwort seit dem
|
||||
Hochladen geändert, Schlüssel zufällig erzeugt), fragt `login` nach dem Schlüssel
|
||||
(diora: Einstellungen → Schlüssel kopieren; `--paste-key` fragt gleich danach).
|
||||
|
||||
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.
|
||||
Token und Schlüssel liegen geschützt in `config.json`: unter Windows per DPAPI (nur dieser
|
||||
Windows-Benutzer auf diesem Rechner kann sie lesen), sonst als Base64 in einer 0600-Datei.
|
||||
Eine ältere Konfiguration mit Klartextfeldern wird beim ersten Lesen geschützt neu geschrieben.
|
||||
Alle Dateien liegen unter `platformdirs`' Verzeichnissen; `DIORA_TUI_HOME=<Ordner>` legt sie
|
||||
stattdessen gemeinsam in einen Ordner (zweites Konto, Tests).
|
||||
|
||||
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
|
||||
`~/.config/diora-tui/config.json`, `chmod 600`):
|
||||
**Was synchronisiert wird:** die Bücherliste (`GET /books/`, ohne die Blobs der Markierungen)
|
||||
samt Lesestand, Gelesen-Status und der Uhr des Servers. Die Bücher selbst werden erst beim
|
||||
Öffnen geladen und entschlüsselt (`<id> - <Titel>.epub` im Bibliotheksordner). PDFs werden
|
||||
übersprungen.
|
||||
|
||||
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
|
||||
- **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)`.
|
||||
**Lesestand** (`progress.py`, `reader_screen.py`):
|
||||
|
||||
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.
|
||||
- Gespeichert wird nur, was der Leser selbst bewegt hat (Tasten, Mausrad), mit dem Zeitpunkt
|
||||
des Ankommens (`read_at`). Wiederherstellen, Umbruch bei Größenänderung und Sprünge auf
|
||||
Angebote ändern nichts. Ein offenes, ruhendes Buch kann deshalb nie eine neuere Stelle von
|
||||
woanders überschreiben.
|
||||
- Zeiten sind Serverzeit: die TUI lernt aus jeder Antwort den Versatz ihrer Uhr, und der
|
||||
Server korrigiert mit `sent_at` nach. Ein falsch gehender Rechner ordnet trotzdem richtig.
|
||||
- Jede Stelle wird lokal angewendet und vorgemerkt (`pending`, je Buch die später gelesene),
|
||||
bis der Server sie hat, auch offline; nach einem Abgleich wird sie nach derselben Regel auf
|
||||
den Stand des Servers gelegt.
|
||||
- Alle zwei Minuten fragt ein offenes Buch den Server. Liegt dort eine neuere Stelle von einem
|
||||
anderen Gerät, fragt die TUI „Auf *X* weitergelesen — bei n %. Springen?“ (`y`/`n`), und
|
||||
bewegt sich nie von allein.
|
||||
- Der Fortschritt in % ist wie im Web der Anteil am Buchtext in Zeichen
|
||||
(`blocks.fraction_for_anchor`), nicht mehr 0.
|
||||
|
||||
## Performance bei großen Büchern
|
||||
|
||||
|
|
@ -115,10 +158,32 @@ deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße —
|
|||
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
|
||||
auf einmal.
|
||||
|
||||
## Tests und Anker-Parität mit dem Web-Reader
|
||||
|
||||
```bash
|
||||
pip install -e . pytest
|
||||
pytest tests # Blockliste, Anker, App-Start (Textual-Pilot)
|
||||
```
|
||||
|
||||
Die Position ist ein Block-Index, den alle Clients gleich zählen müssen. Ob die TUI das tut,
|
||||
prüft `tools/parity/` gegen den echten Web-Code (`parseEpub` aus `static/js/app/40-epub.js`
|
||||
in headless Chromium):
|
||||
|
||||
```bash
|
||||
NODE_PATH=<diora-web>/node_modules PLAYWRIGHT_BROWSERS_PATH=... node tools/parity/web_blocks.cjs <diora-web> web.json buch1.epub buch2.epub
|
||||
python tools/parity/compare_blocks.py web.json
|
||||
```
|
||||
|
||||
Stand 2026-10-26 (6 Bücher, bis 25.798 Blöcke): Anzahl, Tags, Text, Zeichenposition und
|
||||
-länge aller Blöcke sowie die Gesamtlänge sind identisch. Das setzt voraus, dass die TUI den
|
||||
EPUB-Inhalt wie der Browser liest: UTF-8-dekodiert, mit dem HTML5-Parser (`html5lib`, nicht
|
||||
libxml2) und aus den Rohdaten (`item.content`; `get_content()` von ebooklib formatiert um und
|
||||
fügt Leerraum ein).
|
||||
|
||||
## Grenzen der aktuellen Version
|
||||
|
||||
- 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.
|
||||
- Nur EPUB, kein PDF (der Abgleich überspringt PDFs und meldet sie); keine PDF-Markierungen.
|
||||
- Notizen sind einzeilig; mehrzeilige Notizen aus dem Web werden angezeigt und lassen sich ersetzen.
|
||||
- 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
|
||||
|
|
|
|||
36
tui/TODO.md
Normal file
36
tui/TODO.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# TODO diora-tui
|
||||
|
||||
## Offen
|
||||
|
||||
- [ ] **Pull Request `wt-tui` → `master` anlegen** (nur anlegen, nicht mergen). Forgejo verlangt dafür eine
|
||||
Anmeldung; Claude hat keinen Token. Vergleich ist vorbereitet:
|
||||
https://fg.creamfresh.xyz/mrwnslz/diora-web/compare/master...wt-tui
|
||||
(Stand 2026-10-26: 14 Commits, mit dem damaligen `origin/master` konfliktfrei mergebar.)
|
||||
|
||||
**Titel:** `diora-tui: auf den aktuellen Stand (Lesestand, Bibliothek, Markierungen)`
|
||||
|
||||
**Beschreibung:**
|
||||
|
||||
```
|
||||
Bringt die TUI (tui/) auf den Stand von Web und Apps. Nur tui/ ist betroffen, der Server bleibt unverändert.
|
||||
|
||||
- Liest EPUBs wie der Browser (html5lib, Rohdaten, UTF-8): Block-Index, Text und Zeichenpositionen stimmen bei 6 echten Büchern mit parseEpub des Web-Readers überein (tools/parity/).
|
||||
- Lesestand "zuletzt gelesen gewinnt" nach docs/progress-sync.md (read_at/sent_at/device, Warteschlange, Angebote "Auf X weitergelesen" und "Weiteste Stelle"); Fortschritt als Textanteil statt 0.0.
|
||||
- `diora-tui login` wie diora_client (Token auslesen, Schlüssel aus dem Passwort, geschützt gespeichert: DPAPI unter Windows), Bücher erst beim Öffnen geladen, Abgleich über GET /books/.
|
||||
- Bibliothek wie im Web: Weiterlesen-Karte, Status- und Ordner-Chips, Suche, 6 Sortierungen inkl. Länge, Bücher/Zeitschriften.
|
||||
- Markierungen, Notizen, Lesezeichen anzeigen, anlegen, löschen; die TUI legt ihre Änderungen je ID auf die aktuelle Server-Liste, bevor sie schreibt.
|
||||
|
||||
Geprüft: 75 Tests (pytest tests), tools/smoke_server.py gegen einen lokalen Server, tools/parity/web_anchors.cjs (der echte resolveAnchorRange des Webs findet 81 TUI-Anker exakt; 34 von 34 echten Web-Markierungen werden angezeigt).
|
||||
Nicht enthalten: Podcasts/Radio, PDF-Markierungen, Serien-Stapel, Ausgaben, Cover.
|
||||
```
|
||||
|
||||
- [ ] **Forgejo-Zugang für Claude** (optional): API-Token als Umgebungsvariable oder `tea` einrichten, dann
|
||||
legt Claude künftige PRs selbst an.
|
||||
- [ ] **TUI selbst ausprobieren** mit dem echten Konto: `pip install -e tui`, `diora-tui login`,
|
||||
`diora-tui`. Bisher nur in automatisierten Terminal-Läufen und gegen einen lokalen Testserver
|
||||
gesehen, nicht von Hand bedient.
|
||||
|
||||
## Bewusst nicht in der TUI
|
||||
|
||||
Podcasts und Radio, PDF-Markierungen, Serien-Stapel, Ausgaben (mehrere Fassungen eines Werks),
|
||||
Cover, Ordner anlegen/zuweisen, Eingang aus der Cloud, Heft-Ansicht für Zeitschriften.
|
||||
179
tui/diora_tui/annotation_screens.py
Normal file
179
tui/diora_tui/annotation_screens.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""The reader's note editor and the list of a book's bookmarks, highlights and notes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from rich.text import Text
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Label, ListItem, ListView, Static
|
||||
|
||||
from . import annotations, blocks
|
||||
|
||||
|
||||
class NoteScreen(ModalScreen[str]):
|
||||
"""Type a note; Enter keeps it, Escape leaves it as it was (dismisses with None)."""
|
||||
|
||||
BINDINGS = [Binding("escape", "cancel", "Abbrechen")]
|
||||
|
||||
def __init__(self, title: str, text: str = "") -> None:
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._text = text
|
||||
|
||||
def compose(self):
|
||||
with Container(id="note-body"):
|
||||
yield Label(self._title, id="note-title")
|
||||
yield Input(value=self._text, placeholder="Notiz …", id="note-input")
|
||||
yield Label("Enter: speichern Escape: abbrechen", id="note-hint")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(Input).focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
self.dismiss(event.value.strip())
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
kind: str # 'bookmarks' | 'highlights'
|
||||
item: dict
|
||||
block: int # where it belongs, for the jump
|
||||
percent: int
|
||||
|
||||
|
||||
def entries(book: blocks.FlatBook, ann: annotations.BookAnnotations, mark_index=None) -> list[Entry]:
|
||||
out: list[Entry] = []
|
||||
for bm in ann.bookmarks:
|
||||
block = bookmark_block(book, bm)
|
||||
out.append(Entry("bookmarks", bm, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
|
||||
for h in sorted(ann.highlights, key=annotations.sort_key):
|
||||
a = h.get("anchor") or {}
|
||||
block = a.get("startBlockIndex") if isinstance(a.get("startBlockIndex"), int) else None
|
||||
if block is None and mark_index is not None: # an old anchor: where its quote is
|
||||
found = mark_index._find_quote(a)
|
||||
block = found[0] if found else None
|
||||
block = min(max(block or 0, 0), len(book.blocks) - 1)
|
||||
out.append(Entry("highlights", h, block, round(blocks.fraction_for_anchor(book, block, 0.0) * 100)))
|
||||
return out
|
||||
|
||||
|
||||
def bookmark_block(book: blocks.FlatBook, bm: dict) -> int:
|
||||
"""The block a bookmark points at: its chapter's first block; else its fraction of the text."""
|
||||
src = bm.get("anchor") or ""
|
||||
if src in book.chapter_srcs:
|
||||
return book.chapter_start_block[book.chapter_srcs.index(src)]
|
||||
target = float(bm.get("scrollFraction") or 0) * book.total_chars
|
||||
return next((i for i, b in enumerate(book.blocks) if b.char_start + b.char_len >= target), 0)
|
||||
|
||||
|
||||
class AnnotationsScreen(ModalScreen[int]):
|
||||
"""Lesezeichen, then Markierungen and Notizen in the order of the book. Enter jumps (dismisses
|
||||
with the block), d deletes, e edits a note; Escape closes."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape,q,a", "close", "Schließen"),
|
||||
Binding("d", "delete", "Löschen"),
|
||||
Binding("e", "edit", "Notiz"),
|
||||
]
|
||||
|
||||
def __init__(self, book: blocks.FlatBook, store: annotations.AnnotationStore, key: str, on_change, mark_index=None) -> None:
|
||||
super().__init__()
|
||||
self.book = book
|
||||
self.store = store
|
||||
self.key = key
|
||||
self.mark_index = mark_index
|
||||
self.on_change = on_change # called after a delete or an edit: marks and the queue follow
|
||||
self.rows: list[Entry] = []
|
||||
|
||||
def compose(self):
|
||||
with Container(id="ann-body"):
|
||||
yield Label("Lesezeichen, Markierungen und Notizen", id="ann-title")
|
||||
yield ListView(id="ann-list")
|
||||
yield Static("", id="ann-detail")
|
||||
yield Label("Enter: hinspringen d: löschen e: Notiz bearbeiten Escape: schließen", id="ann-hint")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._fill()
|
||||
|
||||
def _fill(self, keep: int = 0) -> None:
|
||||
self.rows = entries(self.book, self.store.get(self.key), self.mark_index)
|
||||
lv = self.query_one(ListView)
|
||||
lv.clear()
|
||||
if not self.rows:
|
||||
lv.append(ListItem(Label("Noch nichts. b: Lesezeichen, v: Text markieren, im Lesen.")))
|
||||
for r in self.rows:
|
||||
lv.append(ListItem(Label(Text(self._label(r)))))
|
||||
if self.rows:
|
||||
lv.index = min(keep, len(self.rows) - 1)
|
||||
lv.focus()
|
||||
self._detail()
|
||||
|
||||
@staticmethod
|
||||
def _label(r: Entry) -> str:
|
||||
if r.kind == "bookmarks":
|
||||
return f"★ {r.item.get('label') or 'Lesezeichen'} {r.percent} %"
|
||||
h = r.item
|
||||
quote = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
|
||||
quote = quote if len(quote) <= 60 else quote[:59] + "…"
|
||||
if h.get("type") == "note":
|
||||
return f"✎ {h.get('note') or ''} {r.percent} %"
|
||||
mark = {"yellow": "▌", "green": "▌", "blue": "▌", "red": "▌"}.get(h.get("color") or "yellow", "▌")
|
||||
note = f" ✎ {h['note']}" if h.get("note") else ""
|
||||
return f"{mark} „{quote}“{note} {r.percent} %"
|
||||
|
||||
def _detail(self) -> None:
|
||||
lv = self.query_one(ListView)
|
||||
detail = self.query_one("#ann-detail", Static)
|
||||
if not self.rows or lv.index is None or lv.index >= len(self.rows):
|
||||
detail.update("")
|
||||
return
|
||||
h = self.rows[lv.index].item
|
||||
text = ""
|
||||
if self.rows[lv.index].kind == "highlights":
|
||||
text = " ".join(((h.get("anchor") or {}).get("quote") or "").split())
|
||||
if h.get("note"):
|
||||
text += "\n✎ " + h["note"]
|
||||
detail.update(Text(text))
|
||||
|
||||
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
||||
self._detail()
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
lv = self.query_one(ListView)
|
||||
if self.rows and lv.index is not None and lv.index < len(self.rows):
|
||||
self.dismiss(self.rows[lv.index].block)
|
||||
|
||||
def _current(self) -> Entry | None:
|
||||
lv = self.query_one(ListView)
|
||||
return self.rows[lv.index] if self.rows and lv.index is not None and lv.index < len(self.rows) else None
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
def action_delete(self) -> None:
|
||||
r = self._current()
|
||||
if r is None:
|
||||
return
|
||||
idx = self.query_one(ListView).index or 0
|
||||
self.store.delete(self.key, r.kind, r.item["id"])
|
||||
self.on_change()
|
||||
self._fill(idx)
|
||||
|
||||
def action_edit(self) -> None:
|
||||
r = self._current()
|
||||
if r is None or r.kind != "highlights":
|
||||
return
|
||||
idx = self.query_one(ListView).index or 0
|
||||
|
||||
def edited(text: str | None) -> None:
|
||||
if text is not None and self.store.set_note(self.key, r.item["id"], text):
|
||||
self.on_change()
|
||||
self._fill(idx)
|
||||
|
||||
self.app.push_screen(NoteScreen("Notiz", r.item.get("note") or ""), edited)
|
||||
258
tui/diora_tui/annotations.py
Normal file
258
tui/diora_tui/annotations.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""Highlights, notes and bookmarks of a book, as the web reader keeps them.
|
||||
|
||||
Format (static/js/app/72-highlights.js, 68-bookmarks.js): per book and kind one JSON list,
|
||||
encrypted with the book key (AES-GCM, {ct, iv}), replaced as a whole on the server:
|
||||
|
||||
highlight {id, type: 'highlight'|'note', anchor, color, note, createdAt}
|
||||
anchor {type: 'epub', chapterSrc, startBlockIndex, startChar, endBlockIndex, endChar, quote}
|
||||
bookmark {id, label, anchor (the chapter's file), scrollFraction, createdAt} newest first
|
||||
|
||||
The server does no merging, so the apps write their whole list. The TUI keeps what it last
|
||||
saw (`base`), and before it writes it merges its list with the server's current one by id
|
||||
(three-way: what it added, deleted or changed since `base`, on top of what the server holds), so
|
||||
a highlight another device made meanwhile is not wiped out. Its edits are queued (`dirty`) while
|
||||
offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from . import crypto, paths
|
||||
|
||||
COLORS = {"yellow": "#f1c40f", "green": "#2ecc71", "blue": "#3498db", "red": "#e63946"}
|
||||
COLOR_NAMES = {"yellow": "Gelb", "green": "Grün", "blue": "Blau", "red": "Rot"}
|
||||
KINDS = ("highlights", "bookmarks")
|
||||
MAX_BYTES = {"highlights": 700 * 1024, "bookmarks": 100 * 1024} # the server's size guards (raw bytes)
|
||||
QUOTE_MAX = 200
|
||||
|
||||
_DIR = paths.data_dir() / "annotations"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Like the browser's toISOString(): 2026-10-26T12:00:00.123Z."""
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
# ---- the items
|
||||
|
||||
def new_highlight(anchor: dict, color: str = "yellow", note: str = "") -> dict:
|
||||
return {"id": str(uuid.uuid4()), "type": "highlight", "anchor": anchor, "color": color, "note": note, "createdAt": _now_iso()}
|
||||
|
||||
|
||||
def new_note(anchor: dict, text: str) -> dict:
|
||||
"""A freeform note: no mark in the text, its anchor only says where it belongs."""
|
||||
return {"id": str(uuid.uuid4()), "type": "note", "anchor": anchor, "color": None, "note": text, "createdAt": _now_iso()}
|
||||
|
||||
|
||||
def new_bookmark(label: str, anchor: str, scroll_fraction: float) -> dict:
|
||||
return {"id": str(uuid.uuid4()), "label": label, "anchor": anchor, "scrollFraction": scroll_fraction, "createdAt": _now_iso()}
|
||||
|
||||
|
||||
def make_anchor(chapter_src: str, start_block: int, start_char: int, end_block: int, end_char: int, quote: str) -> dict:
|
||||
return {"type": "epub", "chapterSrc": chapter_src, "startBlockIndex": start_block, "startChar": start_char,
|
||||
"endBlockIndex": end_block, "endChar": end_char, "quote": quote[:QUOTE_MAX]}
|
||||
|
||||
|
||||
def sort_key(h: dict) -> tuple:
|
||||
a = h.get("anchor") or {}
|
||||
return (a.get("startBlockIndex", 10**9), a.get("startChar", 0))
|
||||
|
||||
|
||||
# ---- merge
|
||||
|
||||
def merge(base: list[dict] | None, ours: list[dict], theirs: list[dict], *, prepend_added: bool = False) -> list[dict]:
|
||||
"""The server's list (`theirs`) with our changes since `base` laid on it, by id: what we
|
||||
deleted is gone, what we added is in, what we changed is ours (also against their change).
|
||||
`base` None: we never saw the server's list, so nothing counts as deleted."""
|
||||
base_by = {x["id"]: x for x in (base or []) if isinstance(x, dict) and "id" in x}
|
||||
ours_by = {x["id"]: x for x in ours if isinstance(x, dict) and "id" in x}
|
||||
deleted = set(base_by) - set(ours_by) if base is not None else set()
|
||||
added = [x for x in ours if x["id"] not in base_by]
|
||||
changed = {i for i, x in ours_by.items() if i in base_by and x != base_by[i]}
|
||||
out, seen = [], set()
|
||||
for x in theirs:
|
||||
i = x.get("id")
|
||||
if i in deleted:
|
||||
continue
|
||||
out.append(ours_by[i] if i in changed else x)
|
||||
seen.add(i)
|
||||
new = [x for x in added if x["id"] not in seen]
|
||||
# a change of ours to something they deleted stays: losing an edit is worse than a revived item
|
||||
revived = [ours_by[i] for i in changed if i not in seen and ours_by[i] not in new]
|
||||
return (new + revived + out) if prepend_added else (out + revived + new)
|
||||
|
||||
|
||||
# ---- the blobs
|
||||
|
||||
def decode(key_b64: str, blob: dict) -> list[dict]:
|
||||
"""The server's {ct, iv} (ct may be null: none yet) as a list."""
|
||||
if not blob or not blob.get("ct"):
|
||||
return []
|
||||
data = json.loads(crypto.decrypt(key_b64, blob["iv"], blob["ct"]).decode("utf-8"))
|
||||
return [x for x in data if isinstance(x, dict) and x.get("id")] if isinstance(data, list) else []
|
||||
|
||||
|
||||
def encode(key_b64: str, items: list[dict]) -> dict:
|
||||
iv, ct = crypto.encrypt(key_b64, json.dumps(items, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
|
||||
return {"ct": ct, "iv": iv}
|
||||
|
||||
|
||||
# ---- local state
|
||||
|
||||
@dataclass
|
||||
class BookAnnotations:
|
||||
highlights: list[dict] = field(default_factory=list)
|
||||
bookmarks: list[dict] = field(default_factory=list)
|
||||
base: dict[str, list[dict] | None] = field(default_factory=lambda: {"highlights": None, "bookmarks": None})
|
||||
dirty: dict[str, bool] = field(default_factory=lambda: {"highlights": False, "bookmarks": False})
|
||||
|
||||
def items(self, kind: str) -> list[dict]:
|
||||
return self.highlights if kind == "highlights" else self.bookmarks
|
||||
|
||||
|
||||
def _locked(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(self, *a, **kw):
|
||||
with self._lock:
|
||||
return fn(self, *a, **kw)
|
||||
return wrapper
|
||||
|
||||
|
||||
class AnnotationStore:
|
||||
"""One JSON file per book key; shared by the reader (edits) and the sync workers."""
|
||||
|
||||
def __init__(self, directory: Path | None = None) -> None:
|
||||
self.dir = directory or _DIR
|
||||
self._lock = threading.RLock()
|
||||
self._books: dict[str, BookAnnotations] = {}
|
||||
|
||||
def _file(self, key: str) -> Path:
|
||||
return self.dir / f"{key}.json"
|
||||
|
||||
def _load(self, key: str) -> BookAnnotations:
|
||||
if key in self._books:
|
||||
return self._books[key]
|
||||
ann = BookAnnotations()
|
||||
try:
|
||||
raw = json.loads(self._file(key).read_text(encoding="utf-8"))
|
||||
ann.highlights = list(raw.get("highlights") or [])
|
||||
ann.bookmarks = list(raw.get("bookmarks") or [])
|
||||
ann.base = {k: raw.get("base", {}).get(k) for k in KINDS}
|
||||
ann.dirty = {k: bool(raw.get("dirty", {}).get(k)) for k in KINDS}
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass
|
||||
self._books[key] = ann
|
||||
return ann
|
||||
|
||||
def _save(self, key: str) -> None:
|
||||
ann = self._books[key]
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._file(key).with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"highlights": ann.highlights, "bookmarks": ann.bookmarks, "base": ann.base,
|
||||
"dirty": ann.dirty}, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, self._file(key))
|
||||
|
||||
@_locked
|
||||
def get(self, key: str) -> BookAnnotations:
|
||||
"""A copy (the lists are copied, not the items: don't change those)."""
|
||||
a = self._load(key)
|
||||
return BookAnnotations(list(a.highlights), list(a.bookmarks),
|
||||
{k: (list(v) if v is not None else None) for k, v in a.base.items()}, dict(a.dirty))
|
||||
|
||||
@_locked
|
||||
def dirty_keys(self) -> list[str]:
|
||||
keys = set(self._books)
|
||||
if self.dir.exists():
|
||||
keys |= {p.stem for p in self.dir.glob("*.json")}
|
||||
return sorted(k for k in keys if any(self._load(k).dirty.values()))
|
||||
|
||||
# ---- edits (they make the kind dirty: queued for the server)
|
||||
|
||||
@_locked
|
||||
def add(self, key: str, kind: str, item: dict) -> None:
|
||||
ann = self._load(key)
|
||||
if kind == "bookmarks":
|
||||
ann.bookmarks.insert(0, item) # newest first, like the web
|
||||
else:
|
||||
ann.highlights.append(item)
|
||||
ann.dirty[kind] = True
|
||||
self._save(key)
|
||||
|
||||
@_locked
|
||||
def delete(self, key: str, kind: str, item_id: str) -> bool:
|
||||
ann = self._load(key)
|
||||
items = ann.items(kind)
|
||||
kept = [x for x in items if x.get("id") != item_id]
|
||||
if len(kept) == len(items):
|
||||
return False
|
||||
items[:] = kept
|
||||
ann.dirty[kind] = True
|
||||
self._save(key)
|
||||
return True
|
||||
|
||||
@_locked
|
||||
def set_note(self, key: str, item_id: str, text: str) -> bool:
|
||||
ann = self._load(key)
|
||||
for i, h in enumerate(ann.highlights):
|
||||
if h.get("id") == item_id:
|
||||
if h.get("note") == text:
|
||||
return False
|
||||
ann.highlights[i] = {**h, "note": text}
|
||||
ann.dirty["highlights"] = True
|
||||
self._save(key)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---- the server
|
||||
|
||||
@_locked
|
||||
def take_remote(self, key: str, kind: str, theirs: list[dict]) -> None:
|
||||
"""The server's current list: ours becomes it, with our queued edits laid on top."""
|
||||
ann = self._load(key)
|
||||
if ann.dirty[kind]:
|
||||
merged = merge(ann.base[kind], ann.items(kind), theirs, prepend_added=kind == "bookmarks")
|
||||
ann.items(kind)[:] = merged
|
||||
else:
|
||||
ann.items(kind)[:] = theirs
|
||||
ann.base[kind] = list(theirs)
|
||||
self._save(key)
|
||||
|
||||
@_locked
|
||||
def for_push(self, key: str, kind: str, theirs: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
"""(what to write over the server's current list `theirs`, our list as it is now)."""
|
||||
ann = self._load(key)
|
||||
ours = list(ann.items(kind))
|
||||
return merge(ann.base[kind], ours, theirs, prepend_added=kind == "bookmarks"), ours
|
||||
|
||||
@_locked
|
||||
def pushed(self, key: str, kind: str, written: list[dict], snapshot: list[dict]) -> None:
|
||||
"""The server holds `written`, made from our list `snapshot`. Ours becomes `written`;
|
||||
what was edited while it was on its way stays queued on top."""
|
||||
ann = self._load(key)
|
||||
current = ann.items(kind)
|
||||
ann.base[kind] = list(written)
|
||||
if current == snapshot:
|
||||
current[:] = written
|
||||
ann.dirty[kind] = False
|
||||
else:
|
||||
current[:] = merge(snapshot, current, written, prepend_added=kind == "bookmarks")
|
||||
ann.dirty[kind] = True
|
||||
self._save(key)
|
||||
|
||||
|
||||
_default: AnnotationStore | None = None
|
||||
|
||||
|
||||
def default() -> AnnotationStore:
|
||||
global _default
|
||||
if _default is None:
|
||||
_default = AnnotationStore()
|
||||
return _default
|
||||
|
|
@ -1,48 +1,84 @@
|
|||
"""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.
|
||||
"""HTTP client for the diora server, authenticated by the account's API token
|
||||
(Authorization: Bearer; the server sets request.user for every endpoint).
|
||||
|
||||
Only what the reader needs: the book list with the reading state (`GET /books/` – the same
|
||||
rows the apps pull, without the notes' blobs that `/api/sync/` carries), a book's encrypted
|
||||
file, and a position write (`POST /books/<id>/progress/`, docs/progress-sync.md).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
USER_AGENT = "diora-tui"
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
return {"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT}
|
||||
|
||||
|
||||
def fetch_sync_snapshot(server_url: str, token: str) -> dict:
|
||||
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
|
||||
def _check(resp: requests.Response, what: str) -> None:
|
||||
if resp.status_code == 401:
|
||||
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||
raise ApiError("Anmeldung abgelehnt — Token ungültig oder neu erzeugt? `diora-tui login` ausführen.")
|
||||
if resp.status_code == 404:
|
||||
raise ApiError(f"{what} nicht gefunden (gelöscht oder anderes Konto?)")
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def fetch_books(server_url: str, token: str) -> tuple[list[dict], int | None, int]:
|
||||
"""(books, the server's clock in ms from X-Server-Now or None, this machine's clock in ms when
|
||||
the answer came). Each book: id, meta_ct, meta_iv, uploaded_at, is_read, has_cover and the
|
||||
reading state (scroll_fraction, position_anchor, read_at, device, furthest_*)."""
|
||||
resp = requests.get(f"{server_url}/books/", headers=_headers(token), timeout=60)
|
||||
device_now = int(time.time() * 1000)
|
||||
_check(resp, "Bücherliste")
|
||||
try:
|
||||
server_now = int(resp.headers.get("X-Server-Now", ""))
|
||||
except ValueError:
|
||||
server_now = None
|
||||
return resp.json(), server_now, device_now
|
||||
|
||||
|
||||
def fetch_prefs(server_url: str, token: str) -> dict:
|
||||
"""The library's encrypted settings: {prefs_ct, prefs_iv}, both '' while there are none."""
|
||||
resp = requests.get(f"{server_url}/books/prefs/", headers=_headers(token), timeout=30)
|
||||
_check(resp, "Bibliothekseinstellungen")
|
||||
return resp.json()
|
||||
|
||||
|
||||
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()
|
||||
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=300)
|
||||
_check(resp, f"Buch {book_id}")
|
||||
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()
|
||||
def fetch_annotations(server_url: str, token: str, book_id: int, kind: str) -> dict:
|
||||
"""A book's encrypted highlights or bookmarks: {ct, iv} (both null while there are none)."""
|
||||
resp = requests.get(f"{server_url}/books/{book_id}/{kind}/", headers=_headers(token), timeout=60)
|
||||
_check(resp, f"Buch {book_id}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def post_annotations(server_url: str, token: str, book_id: int, kind: str, blob: dict) -> None:
|
||||
"""Replace a book's highlights or bookmarks as a whole (the server doesn't merge)."""
|
||||
resp = requests.post(f"{server_url}/books/{book_id}/{kind}/", headers=_headers(token), json=blob, timeout=60)
|
||||
_check(resp, f"Buch {book_id}")
|
||||
|
||||
|
||||
def post_progress(server_url: str, token: str, book_id: int, *, anchor: str, fraction: float, read_at: int,
|
||||
device: str, force: bool = False) -> dict:
|
||||
"""Send a queued position. `sent_at` is the moment it leaves: the server corrects this
|
||||
machine's clock error with it. The answer is the state after the write."""
|
||||
body = {"scroll_fraction": fraction, "position_anchor": anchor, "read_at": read_at,
|
||||
"sent_at": int(time.time() * 1000), "device": device}
|
||||
if force:
|
||||
body["force"] = True
|
||||
resp = requests.post(f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30)
|
||||
_check(resp, f"Buch {book_id}")
|
||||
return resp.json()
|
||||
|
|
|
|||
|
|
@ -1,113 +1,21 @@
|
|||
"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress
|
||||
"""diora-tui: terminal EPUB reader for diora, syncing books + reading position
|
||||
against a diora server (see project README)."""
|
||||
|
||||
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 textual.app import App
|
||||
|
||||
from . import config as config_mod
|
||||
from . import crypto, epub, library_meta, progress, remote
|
||||
from .reader_screen import ReaderScreen
|
||||
from .statusbar import StatusBar
|
||||
from . import login as login_mod, remote
|
||||
from .library_screen import LibraryScreen
|
||||
|
||||
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"
|
||||
|
|
@ -123,87 +31,45 @@ class DioraTuiApp(App):
|
|||
cfg = config_mod.load()
|
||||
if cfg is not None:
|
||||
try:
|
||||
# Sends what is queued, then pulls: nothing read stays only here.
|
||||
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?"):
|
||||
def run_login(server: str | None, *, paste_key: bool) -> None:
|
||||
try:
|
||||
cfg = login_mod.login(server, paste_key=paste_key)
|
||||
except login_mod.LoginError as e:
|
||||
raise SystemExit(f"Fehler: {e}")
|
||||
config_mod.save(cfg)
|
||||
print("Gespeichert.")
|
||||
return cfg
|
||||
print("Gespeichert (Token und Schlüssel geschützt). Weiter mit diora-tui oder diora-tui sync.")
|
||||
|
||||
|
||||
def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None:
|
||||
cfg = _resolve_remote_config(server_override, save=save)
|
||||
def run_sync(library_dir: Path) -> None:
|
||||
cfg = config_mod.load()
|
||||
if cfg is None:
|
||||
raise SystemExit("Nicht angemeldet: erst diora-tui login")
|
||||
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.")
|
||||
print(f"{result.books} EPUB(s) auf dem Server (geladen wird beim Öffnen)." )
|
||||
if result.pushed:
|
||||
print(f"Lesestand von {result.pushed} Buch/Bücher an den Server geschickt.")
|
||||
for title in result.new_titles:
|
||||
print(f" + {title}")
|
||||
if result.skipped_pdf:
|
||||
titles = ", ".join(result.skipped_pdf)
|
||||
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
|
||||
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest nur EPUB): {', '.join(result.skipped_pdf)}")
|
||||
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.")
|
||||
print(f"{len(result.failed)} Buch/Bücher nicht entschlüsselbar (falscher Schlüssel?): {', '.join(result.failed)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description="diora-tui — EPUB-Reader im Terminal, synct mit diora")
|
||||
parser.add_argument(
|
||||
"--library",
|
||||
type=Path,
|
||||
|
|
@ -211,17 +77,23 @@ def main() -> None:
|
|||
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"
|
||||
)
|
||||
login_parser = subparsers.add_parser("login", help="Einmal anmelden (Token + Schlüssel, geschützt gespeichert)")
|
||||
login_parser.add_argument("--server", help=f"Server-URL (Standard: {login_mod.DEFAULT_SERVER})")
|
||||
login_parser.add_argument("--paste-key", action="store_true", help="Buchschlüssel einfügen statt aus dem Passwort ableiten")
|
||||
subparsers.add_parser("logout", help="Gespeicherte Zugangsdaten löschen")
|
||||
subparsers.add_parser("sync", help="Bücherliste und Lesestand abgleichen (ohne die Oberfläche)")
|
||||
|
||||
args = parser.parse_args()
|
||||
library_dir = args.library.expanduser()
|
||||
|
||||
if args.command == "login":
|
||||
run_login(args.server, paste_key=args.paste_key)
|
||||
return
|
||||
if args.command == "logout":
|
||||
print("Zugangsdaten gelöscht." if config_mod.forget() else "Keine Zugangsdaten gespeichert.")
|
||||
return
|
||||
if args.command == "sync":
|
||||
run_sync(library_dir, args.server, save=args.save)
|
||||
run_sync(library_dir)
|
||||
return
|
||||
|
||||
DioraTuiApp(library_dir).run()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,46 @@
|
|||
height: 1fr;
|
||||
}
|
||||
|
||||
#lib-chips {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#lib-search {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#lib-card {
|
||||
height: auto;
|
||||
margin: 1 1 0 1;
|
||||
padding: 0 2;
|
||||
border: round $primary;
|
||||
}
|
||||
|
||||
#lib-note {
|
||||
height: auto;
|
||||
padding: 0 2;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
ChoiceScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#choice-title {
|
||||
width: 50;
|
||||
background: $panel;
|
||||
padding: 0 2;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#choice-list {
|
||||
width: 50;
|
||||
height: auto;
|
||||
max-height: 70%;
|
||||
border: round $primary;
|
||||
}
|
||||
|
||||
StatusBar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
|
|
@ -47,3 +87,75 @@ FootnoteScreen {
|
|||
color: $text-muted;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
JumpOfferScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#offer-body {
|
||||
width: 60;
|
||||
height: auto;
|
||||
background: $panel;
|
||||
border: round $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#offer-hint {
|
||||
margin-top: 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
NoteScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#note-body {
|
||||
width: 70;
|
||||
height: auto;
|
||||
background: $panel;
|
||||
border: round $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#note-title {
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#note-hint {
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
AnnotationsScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#ann-body {
|
||||
width: 90%;
|
||||
max-width: 110;
|
||||
height: 80%;
|
||||
background: $panel;
|
||||
border: round $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#ann-title {
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#ann-list {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#ann-detail {
|
||||
height: auto;
|
||||
max-height: 6;
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#ann-hint {
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,15 +25,17 @@ from pathlib import Path
|
|||
|
||||
import ebooklib
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
|
||||
from bs4.element import PreformattedString
|
||||
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"
|
||||
# html5lib builds the tree with the HTML5 algorithm, as the browser's DOMParser the web reader
|
||||
# uses does: same nesting for broken markup (`<h1/>` opens a block) and the same text nodes
|
||||
# (whitespace included), which the block indexes and the character offsets depend on. lxml's
|
||||
# HTML parser (libxml2) trims indentation and closes elements differently.
|
||||
_PARSER = "html5lib"
|
||||
|
||||
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
|
||||
# blockquote, dt, dd, figcaption, div:not(:has(*))'
|
||||
|
|
@ -43,6 +45,9 @@ _BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt"
|
|||
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
|
||||
# Matches app.js's _looksLikeFootnoteLink's class-name check.
|
||||
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
|
||||
# JavaScript's `\s`, not Python's (which also matches \x1c-\x1f and \x85 but not ): a
|
||||
# block's text is cut and measured the way the web reader does.
|
||||
_JS_SPACE_RE = re.compile("[ \t\n\v\f\r -
]+")
|
||||
|
||||
# Anchor format the server accepts (books/views.py:save_progress); anything else
|
||||
# is silently discarded back to ''.
|
||||
|
|
@ -70,6 +75,12 @@ class Block:
|
|||
# walk-up in app.js), so a target id resolves to "the block containing
|
||||
# it" rather than requiring the id to sit on the block tag itself.
|
||||
ids: list[str] = field(default_factory=list)
|
||||
# Where the block sits in the book's text, counted in characters the way the web reader
|
||||
# counts them (44-books.js _bookCharIndex): char_start = offset of the block's first text
|
||||
# node among all text nodes of the book, char_len = its textContent length (nested blocks
|
||||
# included). Progress as a share of the text (position -> fraction) is computed from these.
|
||||
char_start: int = 0
|
||||
char_len: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -82,6 +93,10 @@ class FlatBook:
|
|||
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
|
||||
total_chars: int = 1 # all text nodes of the book (never 0: it divides)
|
||||
# chapter i's file as the web reader names it (data-epub-src: the OPF's folder + the manifest
|
||||
# href): what highlights and bookmarks store as chapterSrc / anchor
|
||||
chapter_srcs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _is_leaf_div(tag: Tag) -> bool:
|
||||
|
|
@ -102,13 +117,15 @@ def _looks_like_footnote_link(el: Tag, href: str) -> bool:
|
|||
|
||||
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
|
||||
for child in el.children:
|
||||
if isinstance(child, PreformattedString):
|
||||
continue # comments, CDATA, declarations: not text (textContent skips them)
|
||||
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)
|
||||
marker = _JS_SPACE_RE.sub(" ", child.get_text("")).strip(" ")
|
||||
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))
|
||||
|
|
@ -117,31 +134,62 @@ def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
|
|||
_walk_text(child, footnotes, out)
|
||||
|
||||
|
||||
def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]:
|
||||
soup = BeautifulSoup(html, _PARSER)
|
||||
def _text_nodes(el: Tag):
|
||||
for node in el.descendants:
|
||||
if isinstance(node, NavigableString) and not isinstance(node, PreformattedString):
|
||||
yield node
|
||||
|
||||
|
||||
def _extract_blocks(html: bytes, chapter_index: int) -> tuple[list[Block], int]:
|
||||
"""The chapter's blocks (char_start relative to the chapter) and its text length in chars."""
|
||||
# Decoded as UTF-8 first, as the web reader reads the file (JSZip 'text'); handing html5lib
|
||||
# bytes would have it guess an encoding.
|
||||
soup = BeautifulSoup(html.decode("utf-8", "replace"), _PARSER)
|
||||
for name in _STRIP_TAGS:
|
||||
for el in soup.find_all(name):
|
||||
el.decompose()
|
||||
|
||||
root = soup.body or soup
|
||||
blocks: list[Block] = []
|
||||
for el in soup.find_all(True):
|
||||
if el.name in _BLOCK_TAGS or _is_leaf_div(el):
|
||||
offset = 0
|
||||
for node in root.descendants:
|
||||
if isinstance(node, Tag):
|
||||
if node.name in _BLOCK_TAGS or _is_leaf_div(node):
|
||||
blocks.append(_make_block(node, chapter_index, offset))
|
||||
elif isinstance(node, NavigableString) and not isinstance(node, PreformattedString):
|
||||
offset += len(node)
|
||||
return blocks, offset
|
||||
|
||||
|
||||
def _make_block(el: Tag, chapter_index: int, char_start: int) -> Block:
|
||||
footnotes: list[FootnoteRef] = []
|
||||
parts: list[str] = []
|
||||
_walk_text(el, footnotes, parts)
|
||||
text = re.sub(r"\s+", " ", "".join(parts)).strip()
|
||||
text = _JS_SPACE_RE.sub(" ", "".join(parts)).strip(" ")
|
||||
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id]
|
||||
blocks.append(
|
||||
Block(
|
||||
return Block(
|
||||
text=text,
|
||||
tag=el.name,
|
||||
chapter_index=chapter_index,
|
||||
id=el.get("id"),
|
||||
footnotes=footnotes,
|
||||
ids=ids,
|
||||
char_start=char_start,
|
||||
char_len=sum(len(n) for n in _text_nodes(el)),
|
||||
)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def _opf_dir(path: Path) -> str:
|
||||
"""The folder of the book's OPF inside the zip, with a trailing slash ('' at the root)."""
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
m = re.search(r'full-path="([^"]+)"', z.read("META-INF/container.xml").decode("utf-8", "replace"))
|
||||
except (OSError, KeyError, zipfile.BadZipFile):
|
||||
return ""
|
||||
return m.group(1).rsplit("/", 1)[0] + "/" if m and "/" in m.group(1) else ""
|
||||
|
||||
|
||||
def load_flat_book(path: Path) -> FlatBook:
|
||||
|
|
@ -157,6 +205,9 @@ def load_flat_book(path: Path) -> FlatBook:
|
|||
blocks: list[Block] = []
|
||||
chapter_titles: list[str] = []
|
||||
chapter_start_block: list[int] = []
|
||||
chapter_srcs: list[str] = []
|
||||
opf_dir = _opf_dir(path)
|
||||
total_chars = 0
|
||||
|
||||
# app.js's parseEpub() includes every spine itemref unconditionally — no
|
||||
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
|
||||
|
|
@ -168,16 +219,22 @@ def load_flat_book(path: Path) -> FlatBook:
|
|||
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
||||
continue
|
||||
chapter_index = len(chapter_titles)
|
||||
chapter_blocks = _extract_blocks(item.get_content(), chapter_index)
|
||||
chapter_blocks, chapter_chars = _extract_blocks(item.content, chapter_index) # raw bytes: EpubHtml.get_content() pretty-prints, adding whitespace
|
||||
for b in chapter_blocks:
|
||||
b.char_start += total_chars
|
||||
total_chars += chapter_chars
|
||||
chapter_start_block.append(len(blocks))
|
||||
first_text = next((b.text for b in chapter_blocks if b.text), None)
|
||||
chapter_titles.append((first_text or item.get_name())[:60])
|
||||
chapter_srcs.append(opf_dir + item.file_name)
|
||||
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)
|
||||
if not b.char_len: # no text of its own: where the previous block ends (0 for the first), as the web reader
|
||||
b.char_start = blocks[idx - 1].char_start + blocks[idx - 1].char_len if idx else 0
|
||||
|
||||
return FlatBook(
|
||||
id=_book_id(path),
|
||||
|
|
@ -188,9 +245,20 @@ def load_flat_book(path: Path) -> FlatBook:
|
|||
chapter_titles=chapter_titles,
|
||||
chapter_start_block=chapter_start_block,
|
||||
footnote_targets=footnote_targets,
|
||||
total_chars=total_chars or 1,
|
||||
chapter_srcs=chapter_srcs,
|
||||
)
|
||||
|
||||
|
||||
def fraction_for_anchor(book: FlatBook, block_index: int, inner_fraction: float) -> float:
|
||||
"""The share of the book's text before the anchored place (0..1), as the web reader's
|
||||
_fractionFromAnchor: what other clients show as progress."""
|
||||
if not 0 <= block_index < len(book.blocks):
|
||||
return 0.0
|
||||
b = book.blocks[block_index]
|
||||
return max(0.0, min(1.0, (b.char_start + inner_fraction * b.char_len) / book.total_chars))
|
||||
|
||||
|
||||
def format_anchor(block_index: int, inner_fraction: float) -> str:
|
||||
inner_fraction = max(0.0, min(1.0, inner_fraction))
|
||||
return f"{block_index}:{inner_fraction:.6f}"
|
||||
|
|
@ -212,3 +280,75 @@ def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
|
|||
nb, ni = new_parts
|
||||
ob, oi = old_parts
|
||||
return ni >= oi if nb == ob else nb >= ob
|
||||
|
||||
|
||||
def book_pages(path: Path) -> int:
|
||||
"""The book's length in printed pages from its text alone (bookPagesOfFile, 40-epub.js): every
|
||||
spine document's body text by 1800 characters a page. Cheaper than load_flat_book: no blocks."""
|
||||
from .library import pages_of_chars
|
||||
|
||||
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
|
||||
chars = 0
|
||||
for idref, _linear in raw.spine:
|
||||
item = raw.get_item_with_id(idref)
|
||||
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
||||
continue
|
||||
soup = BeautifulSoup(item.content.decode("utf-8", "replace"), _PARSER)
|
||||
for name in ("script", "style"):
|
||||
for el in soup.find_all(name):
|
||||
el.decompose()
|
||||
chars += sum(len(n) for n in _text_nodes(soup.body or soup))
|
||||
return pages_of_chars(chars)
|
||||
|
||||
|
||||
# ---- raw text of blocks (for highlights: the web stores character offsets into a block's raw text)
|
||||
|
||||
def raw_block_texts(path: Path, chapter_index: int) -> list[str]:
|
||||
"""The raw text (all text nodes, whitespace as in the file) of every block of one spine
|
||||
document, in block order: what the web's _charOffsetInBlock counts in. `chapter_index` is the
|
||||
index load_flat_book gave the document."""
|
||||
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
|
||||
seen = -1
|
||||
for idref, _linear in raw.spine:
|
||||
item = raw.get_item_with_id(idref)
|
||||
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
||||
continue
|
||||
seen += 1
|
||||
if seen != chapter_index:
|
||||
continue
|
||||
soup = BeautifulSoup(item.content.decode("utf-8", "replace"), _PARSER)
|
||||
for name in _STRIP_TAGS:
|
||||
for el in soup.find_all(name):
|
||||
el.decompose()
|
||||
root = soup.body or soup
|
||||
return ["".join(str(n) for n in _text_nodes(el)) for el in root.descendants
|
||||
if isinstance(el, Tag) and (el.name in _BLOCK_TAGS or _is_leaf_div(el))]
|
||||
return []
|
||||
|
||||
|
||||
def collapse_map(raw: str) -> tuple[list[int], list[int], int]:
|
||||
"""How a block's raw text becomes its displayed text (whitespace runs to one space, the ends
|
||||
cut). Returns (norm_of_raw, raw_of_norm, length):
|
||||
norm_of_raw[i] = offset in the displayed text of raw offset i (0..len(raw));
|
||||
raw_of_norm[k] = raw offset of the character that is displayed character k."""
|
||||
norm_of_raw: list[int] = []
|
||||
raw_of_norm: list[int] = []
|
||||
n = 0
|
||||
started = False
|
||||
prev_space = False
|
||||
for i, ch in enumerate(raw):
|
||||
norm_of_raw.append(n)
|
||||
if _JS_SPACE_RE.fullmatch(ch):
|
||||
if started and not prev_space:
|
||||
raw_of_norm.append(i)
|
||||
n += 1
|
||||
prev_space = True
|
||||
else:
|
||||
started, prev_space = True, False
|
||||
raw_of_norm.append(i)
|
||||
n += 1
|
||||
norm_of_raw.append(n)
|
||||
length = n - 1 if prev_space and started else (n if started else 0)
|
||||
if prev_space and started:
|
||||
raw_of_norm.pop()
|
||||
return [min(x, length) for x in norm_of_raw], raw_of_norm[:length], length
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ paint time, only with viewport height.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Callable
|
||||
|
||||
from textual.geometry import Size
|
||||
from textual.scroll_view import ScrollView
|
||||
from textual.strip import Strip
|
||||
|
|
@ -27,10 +30,28 @@ class ContinuousBookView(ScrollView):
|
|||
}
|
||||
"""
|
||||
|
||||
def __init__(self, book_layout: BookLayout) -> None:
|
||||
def __init__(self, book_layout: BookLayout, on_read_scroll: Callable[[], None] | None = None) -> None:
|
||||
super().__init__()
|
||||
self.book_layout = book_layout
|
||||
self.virtual_size = Size(book_layout.width, book_layout.total_rows)
|
||||
# Called when the reader moves the view (keys, wheel, scrollbar), not when the program does
|
||||
# (restoring a position, re-wrapping after a resize, jumping on an offer): only the first
|
||||
# is "arriving at a place by reading" for the reading position (docs/progress-sync.md).
|
||||
self.on_read_scroll = on_read_scroll
|
||||
self._program_scrolls = 0
|
||||
|
||||
@contextmanager
|
||||
def program_scroll(self):
|
||||
self._program_scrolls += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._program_scrolls -= 1
|
||||
|
||||
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
|
||||
super().watch_scroll_y(old_value, new_value)
|
||||
if old_value != new_value and not self._program_scrolls and self.on_read_scroll is not None:
|
||||
self.on_read_scroll()
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
_scroll_x, scroll_y = self.scroll_offset
|
||||
|
|
|
|||
|
|
@ -14,17 +14,17 @@ import hashlib
|
|||
import pickle
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
from . import paths
|
||||
from .blocks import FlatBook
|
||||
from .layout import BookLayout
|
||||
|
||||
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache"
|
||||
_CACHE_VERSION = 3 # 2: blocks carry char_start/char_len; 3: chapter_srcs
|
||||
_CACHE_DIR = paths.cache_dir() / "layout_cache"
|
||||
|
||||
|
||||
def _cache_key(path: Path, width: int) -> str:
|
||||
stat = path.stat()
|
||||
raw = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
|
||||
raw = f"v{_CACHE_VERSION}|{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
"""Local storage for diora server credentials used by `diora-tui sync`.
|
||||
"""The account the TUI syncs with: server, API token and the book key.
|
||||
|
||||
Mirrors the trust model of the web client's key storage (static/js/app.js,
|
||||
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.
|
||||
Token and key are stored protected (secretstore.py), the server and user name in the clear.
|
||||
A config from before 2026-10 (token and key as plain JSON fields) is still read and is
|
||||
rewritten protected at once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
from . import paths, secretstore
|
||||
|
||||
_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora"))
|
||||
_CONFIG_DIR = paths.config_dir()
|
||||
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
||||
|
||||
|
||||
|
|
@ -24,19 +23,42 @@ class RemoteConfig:
|
|||
server_url: str
|
||||
api_token: str
|
||||
enc_key_b64: str
|
||||
username: 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):
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
if "secret" in data:
|
||||
secret = json.loads(secretstore.unprotect(data["secret"]))
|
||||
return RemoteConfig(data["server_url"], secret["token"], secret["key"], data.get("username", ""))
|
||||
cfg = RemoteConfig(data["server_url"], data["api_token"], data["enc_key_b64"], data.get("username", ""))
|
||||
except (KeyError, ValueError, OSError):
|
||||
return None
|
||||
save(cfg) # a legacy plaintext config: protect it now
|
||||
return cfg
|
||||
|
||||
|
||||
def save(cfg: RemoteConfig) -> None:
|
||||
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2))
|
||||
secret = json.dumps({"token": cfg.api_token, "key": cfg.enc_key_b64}).encode()
|
||||
_CONFIG_FILE.write_text(json.dumps({
|
||||
"server_url": cfg.server_url, "username": cfg.username, "secret": secretstore.protect(secret),
|
||||
}, indent=2))
|
||||
try:
|
||||
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def forget() -> bool:
|
||||
try:
|
||||
_CONFIG_FILE.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ supplies out-of-band.
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
|
@ -32,6 +33,13 @@ def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
|
|||
raise DecryptError(str(e)) from e
|
||||
|
||||
|
||||
def encrypt(key_b64: str, plaintext: bytes) -> tuple[str, str]:
|
||||
"""(iv hex, ciphertext base64): what the web client's encryptBytes sends as iv/ct."""
|
||||
iv = os.urandom(12)
|
||||
ct = AESGCM(base64.b64decode(key_b64)).encrypt(iv, plaintext, None)
|
||||
return iv.hex(), base64.b64encode(ct).decode()
|
||||
|
||||
|
||||
def derive_key_b64(username: str, password: str) -> str:
|
||||
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
|
||||
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
"""Library scanning and the stable per-book id shared across progress.py,
|
||||
blocks.py, and remote.py."""
|
||||
"""Library scanning and the stable per-book key shared across progress.py, blocks.py and
|
||||
remote.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Books downloaded from the server are named "<server id, 4+ digits> - <title>.epub".
|
||||
_SERVER_FILE_RE = re.compile(r"^(\d{4,7}) - ")
|
||||
|
||||
|
||||
def server_id_of(path: Path) -> int | None:
|
||||
m = _SERVER_FILE_RE.match(path.name)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def server_key(server_id: int) -> str:
|
||||
return f"s{server_id}"
|
||||
|
||||
|
||||
def _book_id(path: Path) -> str:
|
||||
# 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.
|
||||
"""A book's key for reading state: the server's id for a downloaded book (the same whether
|
||||
or not, and under whatever name, the file is there), else a hash of its path."""
|
||||
sid = server_id_of(path)
|
||||
if sid is not None:
|
||||
return server_key(sid)
|
||||
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,28 @@ class BookLayout:
|
|||
width: int
|
||||
|
||||
|
||||
def render_block(text: str, width: int, console: Console, spans=()) -> list[Strip]:
|
||||
"""One block wrapped to `width`, a Strip per row. `spans`: (start, end, style) over the text
|
||||
(highlights, a selection); styling never changes how the text wraps."""
|
||||
t = Text(text)
|
||||
for start, end, style in spans:
|
||||
t.stylize(style, start, end)
|
||||
lines = list(t.wrap(console, width)) if text else []
|
||||
if not lines:
|
||||
lines = [Text("")]
|
||||
return [Strip(line.render(console), None).adjust_cell_length(width) for line in lines]
|
||||
|
||||
|
||||
def restyle_block(book_layout: "BookLayout", index: int, text: str, console: Console, spans=()) -> bool:
|
||||
"""Draw block `index` again with `spans`, in place (its rows stay where they are)."""
|
||||
strips = render_block(text, book_layout.width, console, spans)
|
||||
if len(strips) != book_layout.heights[index]:
|
||||
return False
|
||||
start = book_layout.starts[index]
|
||||
book_layout.row_strips[start:start + len(strips)] = strips
|
||||
return True
|
||||
|
||||
|
||||
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
|
||||
width = max(1, width)
|
||||
starts: list[int] = []
|
||||
|
|
@ -40,15 +62,11 @@ def build_layout(block_texts: list[str], width: int, console: Console) -> BookLa
|
|||
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)
|
||||
strips = render_block(text, width, console)
|
||||
heights.append(len(strips))
|
||||
row_strips.extend(strips)
|
||||
row_strips.append(blank)
|
||||
row += len(wrapped_lines) + _SEPARATOR_ROWS
|
||||
row += len(strips) + _SEPARATOR_ROWS
|
||||
|
||||
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
|
||||
|
||||
|
|
|
|||
210
tui/diora_tui/library.py
Normal file
210
tui/diora_tui/library.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""The library list: what the web library shows (docs/library.md, static/js/app/53-library.js)
|
||||
without drawing it — rows, the status and folder chips, search, sort, the "Weiterlesen" card.
|
||||
|
||||
Pure functions on plain rows, so the screen stays thin and the rules can be tested. Where the
|
||||
web has a rule, this follows it (names in the comments are the web's).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
SORTS = [
|
||||
("recent", "Zuletzt"),
|
||||
("title", "Titel"),
|
||||
("author", "Autor"),
|
||||
("added", "Neu hinzugefügt"),
|
||||
("short", "Kürzeste zuerst"),
|
||||
("long", "Längste zuerst"),
|
||||
]
|
||||
STATUSES = [("all", "Alle"), ("started", "Angefangen"), ("unread", "Ungelesen"), ("read", "Gelesen")]
|
||||
|
||||
_MAGAZINE_FOLDER = re.compile(r"^(zeitschriften|magazine|magazines)$", re.I) # _MAGAZINE_FOLDER
|
||||
|
||||
CHARS_PER_BOOK_PAGE = 1800 # the reader's own estimate (44-books.js)
|
||||
RECENT_POOL = 7 # _recentlyReadBooks: the 7 read last
|
||||
RECENT_ROW = 6 # _libRecentBooks: the others shown beside the large one
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
key: str
|
||||
title: str
|
||||
author: str = ""
|
||||
path: Path | None = None # the file, if it is here
|
||||
server_id: int | None = None
|
||||
is_read: bool = False
|
||||
uploaded_at: str = ""
|
||||
folders: list[str] = field(default_factory=list)
|
||||
pages: int = 0 # from the encrypted meta, else worked out here
|
||||
magazine: bool | None = None # meta.magazine; None: decided by the folders
|
||||
read_at: int = 0 # ms, server clock; 0: never opened
|
||||
fraction: float = 0.0
|
||||
device: str = ""
|
||||
|
||||
|
||||
def is_magazine(r: Row) -> bool:
|
||||
return r.magazine is True or (r.magazine is not False and any(_MAGAZINE_FOLDER.match(f.strip()) for f in r.folders))
|
||||
|
||||
|
||||
def folders_of_meta(meta: dict) -> list[str]:
|
||||
"""_foldersOfMeta: `folders`, else the older single `folder`."""
|
||||
f = meta.get("folders")
|
||||
if isinstance(f, list):
|
||||
return [x for x in f if isinstance(x, str) and x.strip()]
|
||||
return [meta["folder"]] if meta.get("folder") else []
|
||||
|
||||
|
||||
def pages_of_chars(chars: int) -> int:
|
||||
"""An EPUB's length in printed pages from its text (bookPagesOfFile): 1800 characters a page."""
|
||||
return max(1, int(chars / CHARS_PER_BOOK_PAGE + 0.5)) if chars else 0
|
||||
|
||||
|
||||
def build_rows(files: list[Path], remote: dict[str, dict], states: dict, local_pages: dict[str, int], key_of) -> list[Row]:
|
||||
"""One row per book: each file in the folder, and each server book not downloaded yet.
|
||||
`states` maps a key to its reading state (progress.BookState), `key_of(path)` is the book key."""
|
||||
rows: dict[str, Row] = {}
|
||||
|
||||
def fill(row: Row, info: dict) -> Row:
|
||||
row.title = info.get("title") or row.title
|
||||
row.author = info.get("author") or ""
|
||||
row.server_id = info.get("server_id")
|
||||
row.is_read = bool(info.get("is_read"))
|
||||
row.uploaded_at = info.get("uploaded_at") or ""
|
||||
row.folders = list(info.get("folders") or [])
|
||||
row.pages = int(info.get("pages") or 0)
|
||||
row.magazine = info.get("magazine")
|
||||
return row
|
||||
|
||||
for path in files:
|
||||
key = key_of(path)
|
||||
rows[key] = fill(Row(key, path.stem, path=path), remote.get(key) or {})
|
||||
for key, info in remote.items():
|
||||
if key not in rows:
|
||||
rows[key] = fill(Row(key, "?"), info)
|
||||
for key, r in rows.items():
|
||||
st = states.get(key)
|
||||
if st is not None:
|
||||
r.read_at = st.read_at or 0
|
||||
r.fraction = st.fraction
|
||||
r.device = st.device
|
||||
if not r.pages:
|
||||
r.pages = int(local_pages.get(key) or 0)
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
# ---- the filters
|
||||
|
||||
def started(r: Row) -> bool:
|
||||
return not r.is_read and r.fraction > 0
|
||||
|
||||
|
||||
def unread(r: Row) -> bool:
|
||||
return not r.is_read and not r.fraction > 0
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
"""_libNorm: without accents, lower case."""
|
||||
return "".join(c for c in unicodedata.normalize("NFD", s or "") if not unicodedata.combining(c)).lower()
|
||||
|
||||
|
||||
def collate(s: str) -> list:
|
||||
"""German-ish order with numbers by value (localeCompare('de', {numeric: true})): accents
|
||||
don't count at first, the plain text breaks ties."""
|
||||
parts = re.split(r"(\d+)", norm(s))
|
||||
return [(0, int(p)) if p.isdigit() else (1, p) for p in parts if p != ""] + [(2, s or "")]
|
||||
|
||||
|
||||
def section(rows: list[Row], magazines: bool) -> list[Row]:
|
||||
"""_bookSectionFilter: Bücher or Zeitschriften."""
|
||||
return [r for r in rows if is_magazine(r) == magazines]
|
||||
|
||||
|
||||
def folder_list(rows: list[Row], order: list[str] | None) -> list[tuple[str, int]]:
|
||||
"""_libFolderList: every folder with its book count, in the user's order, the rest by name."""
|
||||
counts: dict[str, int] = {}
|
||||
for r in rows:
|
||||
for f in r.folders:
|
||||
counts[f] = counts.get(f, 0) + 1
|
||||
order = order or []
|
||||
return sorted(((n, c) for n, c in counts.items()),
|
||||
key=lambda nc: (order.index(nc[0]) if nc[0] in order else 10**6, collate(nc[0])))
|
||||
|
||||
|
||||
def status_counts(rows: list[Row]) -> dict[str, int]:
|
||||
return {"all": len(rows), "started": sum(map(started, rows)), "unread": sum(map(unread, rows)),
|
||||
"read": sum(r.is_read for r in rows)}
|
||||
|
||||
|
||||
def shown(rows: list[Row], *, q: str = "", status: str = "all", folder: str | None = None, sort: str = "recent") -> list[Row]:
|
||||
"""_libShown. `folder`: None all, '' without a folder, else a folder's name."""
|
||||
needle = norm(q).strip()
|
||||
|
||||
def keep(r: Row) -> bool:
|
||||
if status == "started" and not started(r):
|
||||
return False
|
||||
if status == "unread" and not unread(r):
|
||||
return False
|
||||
if status == "read" and not r.is_read:
|
||||
return False
|
||||
if folder is not None and (bool(r.folders) if folder == "" else folder not in r.folders):
|
||||
return False
|
||||
return not needle or needle in norm(r.title + " " + r.author)
|
||||
|
||||
by_title = lambda r: collate(r.title) # noqa: E731
|
||||
out = [r for r in rows if keep(r)]
|
||||
if sort == "title":
|
||||
out.sort(key=by_title)
|
||||
elif sort == "author":
|
||||
out.sort(key=lambda r: (collate(r.author or "~"), by_title(r)))
|
||||
elif sort == "added":
|
||||
out.sort(key=lambda r: r.uploaded_at, reverse=True)
|
||||
elif sort == "short": # a book whose length isn't known comes last
|
||||
out.sort(key=lambda r: (r.pages or float("inf"), by_title(r)))
|
||||
elif sort == "long":
|
||||
out.sort(key=lambda r: (-(r.pages or 0), by_title(r)))
|
||||
else: # recent: read last first, then newest upload
|
||||
out.sort(key=lambda r: (r.read_at, r.uploaded_at), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def continue_rows(rows: list[Row]) -> tuple[Row | None, list[Row]]:
|
||||
"""The "Weiterlesen" card: of the books read last, the first not finished, and the others
|
||||
of those read lately beside it (_libContinueBook, _libRecentBooks)."""
|
||||
pool = sorted((r for r in rows if r.read_at), key=lambda r: r.read_at, reverse=True)[:RECENT_POOL]
|
||||
going = next((r for r in pool if not r.is_read), None)
|
||||
if going is None:
|
||||
return None, []
|
||||
return going, [r for r in pool if r is not going][:RECENT_ROW]
|
||||
|
||||
|
||||
def ago(ms: int, now_ms: int | None = None) -> str:
|
||||
"""_libAgo: "vor 5 Minuten", "gestern", "am 4. Okt."."""
|
||||
if not ms:
|
||||
return ""
|
||||
now = now_ms if now_ms is not None else int(time.time() * 1000)
|
||||
minutes = round((now - ms) / 60000)
|
||||
if minutes < 2:
|
||||
return "gerade eben"
|
||||
if minutes < 60:
|
||||
return f"vor {minutes} Minuten"
|
||||
day = lambda t: time.strftime("%Y-%m-%d", time.localtime(t / 1000)) # noqa: E731
|
||||
hours = round(minutes / 60)
|
||||
if day(ms) == day(now):
|
||||
return "vor einer Stunde" if hours == 1 else f"vor {hours} Stunden"
|
||||
if day(ms) == day(now - 86400000):
|
||||
return "gestern"
|
||||
days = round((now - ms) / 86400000)
|
||||
if days < 7:
|
||||
return f"vor {days} Tagen"
|
||||
months = ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."]
|
||||
t = time.localtime(ms / 1000)
|
||||
return f"am {t.tm_mday}. {months[t.tm_mon - 1]}"
|
||||
|
||||
|
||||
def percent(r: Row) -> int:
|
||||
return 100 if r.is_read else int(r.fraction * 100 + 0.5) # Math.round
|
||||
|
|
@ -1,43 +1,75 @@
|
|||
"""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.
|
||||
"""What the TUI knows about the server's books beyond the files: title, author, read status
|
||||
and which books exist at all (so a book can be listed before it is downloaded). Set only by
|
||||
`sync`; the reader's position is progress.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_data_dir
|
||||
from . import paths
|
||||
|
||||
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||
_DATA_DIR = paths.data_dir()
|
||||
_META_FILE = _DATA_DIR / "library.json"
|
||||
|
||||
|
||||
def _load_all() -> dict[str, dict]:
|
||||
if not _META_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(_META_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = json.loads(_META_FILE.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
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))
|
||||
tmp = _META_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=1))
|
||||
os.replace(tmp, _META_FILE)
|
||||
|
||||
|
||||
def is_read(book_id: str) -> bool:
|
||||
entry = _load_all().get(book_id)
|
||||
def is_read(book_key: str) -> bool:
|
||||
entry = _load_all().get(book_key)
|
||||
return bool(entry and entry.get("is_read"))
|
||||
|
||||
|
||||
def set_many(read_status: dict[str, bool]) -> None:
|
||||
if not read_status:
|
||||
return
|
||||
def get(book_key: str) -> dict | None:
|
||||
return _load_all().get(book_key)
|
||||
|
||||
|
||||
def get_extra(name: str, default=None):
|
||||
"""A setting kept beside the books (stored under '_<name>'): the folder order, the sort."""
|
||||
entry = _load_all().get("_" + name)
|
||||
return entry["value"] if isinstance(entry, dict) and "value" in entry else default
|
||||
|
||||
|
||||
def set_extra(name: str, value) -> None:
|
||||
data = _load_all()
|
||||
for book_id, read in read_status.items():
|
||||
data.setdefault(book_id, {})["is_read"] = read
|
||||
data["_" + name] = {"value": value}
|
||||
_save_all(data)
|
||||
|
||||
|
||||
def set_local_pages(book_key: str, pages: int) -> None:
|
||||
"""A book's length worked out from the file here (the server's meta may not have it)."""
|
||||
data = _load_all()
|
||||
entry = data.setdefault("_pages", {"value": {}})
|
||||
entry.setdefault("value", {})[book_key] = int(pages)
|
||||
_save_all(data)
|
||||
|
||||
|
||||
def local_pages() -> dict[str, int]:
|
||||
return dict(get_extra("pages", {}) or {})
|
||||
|
||||
|
||||
def remote_entries() -> dict[str, dict]:
|
||||
"""Every server book: key -> {server_id, title, author, is_read, uploaded_at}."""
|
||||
return {k: v for k, v in _load_all().items() if isinstance(v, dict) and v.get("server_id") is not None}
|
||||
|
||||
|
||||
def set_remote(entries: dict[str, dict]) -> None:
|
||||
"""Replace the server books with `entries` (a book deleted on the server drops out)."""
|
||||
data = {k: v for k, v in _load_all().items() if not (isinstance(v, dict) and v.get("server_id") is not None)}
|
||||
data.update(entries)
|
||||
_save_all(data)
|
||||
|
|
|
|||
388
tui/diora_tui/library_screen.py
Normal file
388
tui/diora_tui/library_screen.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""The library screen: the web library (docs/library.md) in a terminal — "Weiterlesen" card,
|
||||
status and folder chips, search, sort, Bücher/Zeitschriften. The rules are in library.py.
|
||||
|
||||
Keys: 1–4 status (Alle/Angefangen/Ungelesen/Gelesen), o folder, / search, s sort, m Bücher or
|
||||
Zeitschriften, c continue the book read last, Enter open. The chips and the card also answer
|
||||
the mouse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.text import Text
|
||||
from textual import work
|
||||
from textual.binding import Binding
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.markup import escape
|
||||
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, OptionList, Static
|
||||
|
||||
from . import blocks, config as config_mod, epub, library, library_meta, progress, remote
|
||||
from .reader_screen import ReaderScreen
|
||||
from .statusbar import StatusBar
|
||||
|
||||
PAGES_NOTE_EVERY = 4 # seconds between redraws while lengths are worked out
|
||||
|
||||
|
||||
class ChoiceScreen(ModalScreen[int]):
|
||||
"""Pick one of a few options; dismisses with its index, or None on Escape."""
|
||||
|
||||
BINDINGS = [Binding("escape,q", "cancel", "Abbrechen")]
|
||||
|
||||
def __init__(self, title: str, options: list[str], current: int = 0) -> None:
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._options = options
|
||||
self._current = current
|
||||
|
||||
def compose(self):
|
||||
yield Label(self._title, id="choice-title")
|
||||
yield OptionList(*[Text(o) for o in self._options], id="choice-list")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
ol = self.query_one(OptionList)
|
||||
ol.highlighted = max(0, min(self._current, len(self._options) - 1))
|
||||
ol.focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
self.dismiss(event.option_index)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
class LibraryScreen(Screen):
|
||||
BINDINGS = [
|
||||
Binding("enter", "open_selected", "Öffnen"),
|
||||
Binding("c", "continue_reading", "Weiterlesen"),
|
||||
Binding("/", "search", "Suchen"),
|
||||
Binding("s", "pick_sort", "Sortierung"),
|
||||
Binding("o", "pick_folder", "Ordner"),
|
||||
Binding("m", "toggle_section", "Bücher/Zeitschriften"),
|
||||
Binding("1", "set_status('all')", "Alle", show=False),
|
||||
Binding("2", "set_status('started')", "Angefangen", show=False),
|
||||
Binding("3", "set_status('unread')", "Ungelesen", show=False),
|
||||
Binding("4", "set_status('read')", "Gelesen", show=False),
|
||||
Binding("escape", "reset", "Zurücksetzen", show=False),
|
||||
Binding("q", "quit", "Beenden"),
|
||||
]
|
||||
|
||||
def __init__(self, library_dir: Path) -> None:
|
||||
super().__init__()
|
||||
self.library_dir = library_dir
|
||||
self.rows: list[library.Row] = [] # every book
|
||||
self.section_rows: list[library.Row] = [] # of the section shown (Bücher or Zeitschriften)
|
||||
self.shown: list[library.Row] = [] # the list on screen
|
||||
self.going: library.Row | None = None # the card's book
|
||||
self.folder_names: list[str] = []
|
||||
self.q = ""
|
||||
self.status = "all"
|
||||
self.folder: str | None = None # None: all, '': without folder, else a name
|
||||
self.sort = library_meta.get_extra("sort", "recent")
|
||||
if self.sort not in dict(library.SORTS):
|
||||
self.sort = "recent"
|
||||
self.magazines = False
|
||||
self._lengths_running = False
|
||||
self._lengths_tried: set[str] = set()
|
||||
self._lengths_note = ""
|
||||
|
||||
# ---- layout
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
yield Static("", id="lib-chips")
|
||||
yield Input(placeholder="Titel, Autor …", id="lib-search")
|
||||
yield Static("", id="lib-card")
|
||||
yield Static("", id="lib-note")
|
||||
yield ListView(id="library-list")
|
||||
yield StatusBar()
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("#lib-search").display = False
|
||||
self.sub_title = str(self.library_dir)
|
||||
self._refresh_list()
|
||||
cfg = config_mod.load()
|
||||
if cfg is not None:
|
||||
self._auto_sync(cfg)
|
||||
|
||||
def on_screen_resume(self) -> None:
|
||||
self._refresh_list() # a book was read: the order and the percentages changed
|
||||
|
||||
# ---- sync
|
||||
|
||||
@work(thread=True)
|
||||
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
|
||||
try:
|
||||
result = remote.sync_library(self.library_dir, cfg)
|
||||
except remote.SyncError as e:
|
||||
self.app.call_from_thread(self.notify, str(e), severity="warning", timeout=5)
|
||||
return # best-effort — e.g. offline; the local library still works
|
||||
self.app.call_from_thread(self._on_auto_sync_done, result)
|
||||
|
||||
def _on_auto_sync_done(self, result: remote.SyncResult) -> None:
|
||||
self._refresh_list()
|
||||
if result.new_titles:
|
||||
self.notify(f"{len(result.new_titles)} neue(s) Buch/Bücher auf dem Server.", timeout=3)
|
||||
|
||||
# ---- the list
|
||||
|
||||
def _gather(self) -> list[library.Row]:
|
||||
store = progress.default()
|
||||
remote_books = library_meta.remote_entries()
|
||||
files = epub.scan_library(self.library_dir)
|
||||
keys = {epub._book_id(p) for p in files} | set(remote_books)
|
||||
states = {k: st for k in keys if (st := store.get(k)) is not None}
|
||||
return library.build_rows(files, remote_books, states, library_meta.local_pages(), epub._book_id)
|
||||
|
||||
def _refresh_list(self) -> None:
|
||||
self.rows = self._gather()
|
||||
self.section_rows = library.section(self.rows, self.magazines)
|
||||
order = library_meta.get_extra("folder_order", [])
|
||||
self.folder_names = [n for n, _ in library.folder_list(self.section_rows, order)]
|
||||
if self.folder not in (None, "") and self.folder not in self.folder_names:
|
||||
self.folder = None
|
||||
self._draw()
|
||||
self._fill_lengths()
|
||||
|
||||
def _draw(self) -> None:
|
||||
self._draw_chips()
|
||||
list_view = self.query_one("#library-list", ListView)
|
||||
selected = self.shown[list_view.index].key if self.shown and list_view.index is not None and list_view.index < len(self.shown) else None
|
||||
|
||||
shown = library.shown(self.section_rows, q=self.q, status=self.status, folder=self.folder, sort=self.sort)
|
||||
# The card: only on the plain list, and its book is not repeated below (once, on top).
|
||||
plain = self.status == "all" and self.folder is None and not self.q
|
||||
self.going, recent = library.continue_rows(self.section_rows) if plain else (None, [])
|
||||
self._draw_card(recent)
|
||||
if self.going is not None and self.going in shown:
|
||||
shown.remove(self.going)
|
||||
self.shown = shown
|
||||
|
||||
list_view.clear()
|
||||
if not self.rows:
|
||||
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
|
||||
elif not self.section_rows:
|
||||
list_view.append(ListItem(Label("Noch keine Zeitschriften — m wechselt zu den Büchern." if self.magazines
|
||||
else "Alle Bücher stehen bei den Zeitschriften — m wechselt dorthin.")))
|
||||
elif not self.shown and self.going is None:
|
||||
list_view.append(ListItem(Label("Nichts gefunden.")))
|
||||
by_length = self.sort in ("short", "long")
|
||||
for r in self.shown:
|
||||
list_view.append(ListItem(Label(Text(self._label(r, by_length)))))
|
||||
if self.shown:
|
||||
idx = next((i for i, r in enumerate(self.shown) if r.key == selected), 0)
|
||||
list_view.index = idx
|
||||
if not self.query_one("#lib-search", Input).display: # not while a search is open
|
||||
list_view.focus()
|
||||
self._draw_note()
|
||||
|
||||
@staticmethod
|
||||
def _label(r: library.Row, by_length: bool) -> str:
|
||||
mark = "✓ " if r.is_read else ""
|
||||
cloud = "☁ " if r.path is None else ""
|
||||
author = f" — {r.author}" if r.author else ""
|
||||
pct = f" {library.percent(r)} %" if (r.fraction > 0 and not r.is_read) else ""
|
||||
length = f" · {r.pages} S." if by_length and r.pages else ""
|
||||
return f"{mark}{cloud}{r.title}{author}{pct}{length}"
|
||||
|
||||
def _draw_chips(self) -> None:
|
||||
counts = library.status_counts(self.section_rows)
|
||||
parts = []
|
||||
for i, (sid, label) in enumerate(library.STATUSES):
|
||||
text = label if sid == "all" else f"{label} {counts[sid]}"
|
||||
parts.append(self._chip(f"{i + 1} {text}", f"screen.set_status('{sid}')", self.status == sid))
|
||||
folders = library.folder_list(self.section_rows, library_meta.get_extra("folder_order", []))
|
||||
if folders:
|
||||
parts.append("[dim]│[/]")
|
||||
parts.append(self._chip("Alle Ordner", "screen.set_folder('all')", self.folder is None))
|
||||
for i, (name, count) in enumerate(folders):
|
||||
parts.append(self._chip(f"{name} {count}", f"screen.set_folder('{i}')", self.folder == name))
|
||||
none = sum(1 for r in self.section_rows if not r.folders)
|
||||
if none:
|
||||
parts.append(self._chip(f"Ohne Ordner {none}", "screen.set_folder('none')", self.folder == ""))
|
||||
section = "Zeitschriften" if self.magazines else "Bücher"
|
||||
sort = dict(library.SORTS)[self.sort]
|
||||
head = f"[b]{section}[/] [dim]Sortierung:[/] {escape(sort)} "
|
||||
self.query_one("#lib-chips", Static).update(head + " ".join(parts))
|
||||
|
||||
@staticmethod
|
||||
def _chip(label: str, action: str, on: bool) -> str:
|
||||
label = escape(label)
|
||||
return f"[@click={action}]" + (f"[b reverse] {label} [/]" if on else f" [underline]{label}[/] ") + "[/]"
|
||||
|
||||
def _draw_card(self, recent: list[library.Row]) -> None:
|
||||
card = self.query_one("#lib-card", Static)
|
||||
b = self.going
|
||||
if b is None:
|
||||
card.display = False
|
||||
return
|
||||
card.display = True
|
||||
ago = library.ago(b.read_at)
|
||||
pct = library.percent(b)
|
||||
bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
|
||||
pages = f" · Seite {max(1, int(b.pages * b.fraction + 0.5))} von {b.pages}" if b.pages else ""
|
||||
where = f" · zuletzt auf {escape(b.device)}" if b.device and b.device != progress.DEVICE_NAME else ""
|
||||
lines = [
|
||||
f"[@click=screen.continue_reading][dim]Weiterlesen{' · ' + ago if ago else ''}{where}[/]",
|
||||
f"[b]{escape(b.title)}[/]",
|
||||
escape(b.author) if b.author else "",
|
||||
f"{bar} {pct} %{pages} [dim](c)[/][/]",
|
||||
]
|
||||
if recent:
|
||||
lines.append("[dim]Zuletzt gelesen:[/] " + " · ".join(
|
||||
f"{escape(r.title)}{' ✓' if r.is_read else ''}" for r in recent))
|
||||
card.update("\n".join(l for l in lines if l))
|
||||
|
||||
def _draw_note(self) -> None:
|
||||
note = self.query_one("#lib-note", Static)
|
||||
text = self._lengths_note
|
||||
if not text and self.sort in ("short", "long"):
|
||||
unknown = sum(1 for r in self.section_rows if not r.pages)
|
||||
if unknown:
|
||||
text = f"{unknown} Bücher ohne bekannte Länge (noch nicht geladen) stehen am Ende."
|
||||
note.update(escape(text))
|
||||
note.display = bool(text)
|
||||
|
||||
# ---- lengths (the web works them out once a length sort is chosen: the file is needed)
|
||||
|
||||
def _fill_lengths(self) -> None:
|
||||
if self._lengths_running or self.sort not in ("short", "long"):
|
||||
return
|
||||
todo = [r for r in self.rows if not r.pages and r.path is not None and r.key not in self._lengths_tried]
|
||||
if not todo:
|
||||
return
|
||||
self._lengths_running = True
|
||||
self._work_lengths([(r.key, r.path) for r in todo])
|
||||
|
||||
@work(thread=True, exclusive=True, group="lengths")
|
||||
def _work_lengths(self, todo: list[tuple[str, Path]]) -> None:
|
||||
import time
|
||||
|
||||
shown_at = time.monotonic()
|
||||
try:
|
||||
for done, (key, path) in enumerate(todo, 1):
|
||||
self._lengths_tried.add(key)
|
||||
try:
|
||||
pages = blocks.book_pages(path)
|
||||
except Exception:
|
||||
pages = 0 # stays at the end
|
||||
if pages:
|
||||
library_meta.set_local_pages(key, pages)
|
||||
self._lengths_note = f"Länge wird ermittelt … {done} von {len(todo)} Büchern"
|
||||
if time.monotonic() - shown_at > PAGES_NOTE_EVERY:
|
||||
shown_at = time.monotonic()
|
||||
self.app.call_from_thread(self._refresh_list_keep_note)
|
||||
finally:
|
||||
self._lengths_note = ""
|
||||
self._lengths_running = False
|
||||
self.app.call_from_thread(self._refresh_list)
|
||||
|
||||
def _refresh_list_keep_note(self) -> None:
|
||||
self.rows = self._gather()
|
||||
self.section_rows = library.section(self.rows, self.magazines)
|
||||
self._draw()
|
||||
|
||||
# ---- actions
|
||||
|
||||
def action_set_status(self, status: str) -> None:
|
||||
self.status = "all" if self.status == status else status
|
||||
self._draw()
|
||||
|
||||
def action_set_folder(self, which: str) -> None:
|
||||
"""`which`: 'all', 'none' (without a folder) or the index of a folder chip."""
|
||||
if which == "all":
|
||||
self.folder = None
|
||||
elif which == "none":
|
||||
self.folder = ""
|
||||
elif which.isdigit() and int(which) < len(self.folder_names):
|
||||
name = self.folder_names[int(which)]
|
||||
self.folder = None if self.folder == name else name
|
||||
self._draw()
|
||||
|
||||
def action_pick_folder(self) -> None:
|
||||
values: list[str | None] = [None] + self.folder_names + ([""] if any(not r.folders for r in self.section_rows) else [])
|
||||
if len(values) == 1:
|
||||
self.notify("Keine Ordner angelegt.", timeout=2)
|
||||
return
|
||||
names = ["Alle Ordner" if v is None else "Ohne Ordner" if v == "" else v for v in values]
|
||||
|
||||
def picked(i: int | None) -> None:
|
||||
if i is not None:
|
||||
self.folder = values[i]
|
||||
self._draw()
|
||||
|
||||
self.app.push_screen(ChoiceScreen("Ordner", names, values.index(self.folder)), picked)
|
||||
|
||||
def action_pick_sort(self) -> None:
|
||||
ids = [s for s, _ in library.SORTS]
|
||||
|
||||
def picked(i: int | None) -> None:
|
||||
if i is None:
|
||||
return
|
||||
self.sort = ids[i]
|
||||
library_meta.set_extra("sort", self.sort)
|
||||
self._draw()
|
||||
self._fill_lengths()
|
||||
|
||||
self.app.push_screen(ChoiceScreen("Sortieren nach", [n for _, n in library.SORTS], ids.index(self.sort)), picked)
|
||||
|
||||
def action_toggle_section(self) -> None:
|
||||
self.magazines = not self.magazines
|
||||
self.folder = None
|
||||
self._refresh_list()
|
||||
|
||||
def action_search(self) -> None:
|
||||
box = self.query_one("#lib-search", Input)
|
||||
box.display = True
|
||||
box.focus()
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
self.q = event.value
|
||||
self._draw()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
self.query_one("#library-list", ListView).focus()
|
||||
|
||||
def action_reset(self) -> None:
|
||||
box = self.query_one("#lib-search", Input)
|
||||
if box.display or self.q or self.status != "all" or self.folder is not None:
|
||||
box.value = ""
|
||||
box.display = False
|
||||
self.q, self.status, self.folder = "", "all", None
|
||||
self._draw()
|
||||
|
||||
def action_continue_reading(self) -> None:
|
||||
if self.going is not None:
|
||||
self._open(self.going)
|
||||
else:
|
||||
self.notify("Nichts zum Weiterlesen — ein Buch öffnen.", timeout=3)
|
||||
|
||||
def action_open_selected(self) -> None:
|
||||
list_view = self.query_one("#library-list", ListView)
|
||||
if not self.shown or list_view.index is None or list_view.index >= len(self.shown):
|
||||
return
|
||||
self._open(self.shown[list_view.index])
|
||||
|
||||
def _open(self, row: library.Row) -> None:
|
||||
if row.path is not None:
|
||||
self.app.push_screen(ReaderScreen(row.path))
|
||||
return
|
||||
cfg = config_mod.load()
|
||||
if cfg is None or row.server_id is None:
|
||||
self.notify("Nicht angemeldet — `diora-tui login`.", severity="error")
|
||||
return
|
||||
self.notify(f"Lade „{row.title}“ …", timeout=4)
|
||||
self._download_and_open(cfg, row)
|
||||
|
||||
@work(thread=True, exclusive=True, group="download")
|
||||
def _download_and_open(self, cfg: config_mod.RemoteConfig, row: library.Row) -> None:
|
||||
try:
|
||||
path = remote.download_book(self.library_dir, cfg, row.server_id, row.title)
|
||||
except remote.SyncError as e:
|
||||
self.app.call_from_thread(self.notify, str(e), severity="error", timeout=6)
|
||||
return
|
||||
self.app.call_from_thread(self.app.push_screen, ReaderScreen(path))
|
||||
|
||||
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||
self.action_open_selected()
|
||||
109
tui/diora_tui/login.py
Normal file
109
tui/diora_tui/login.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""`diora-tui login`: sign in once like the apps do and keep what a sync needs.
|
||||
|
||||
The password goes through the web login form (CSRF + session) only to read the account's
|
||||
existing API token off the settings page, and to derive the book key (PBKDF2, as the web
|
||||
client's "unlock with password"). The password is not stored; the session is logged out again.
|
||||
A new token is never created here: that would sign out the apps using the current one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import getpass
|
||||
import html
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from . import api, crypto
|
||||
from .config import RemoteConfig
|
||||
|
||||
DEFAULT_SERVER = "https://diora.creamfresh.xyz"
|
||||
|
||||
_CSRF = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"')
|
||||
_TOKEN = re.compile(r'<code style="user-select:all[^"]*">\s*([A-Za-z0-9_-]+)\s*</code>')
|
||||
_USERNAME = re.compile(r'<meta name="username" content="([^"]*)">')
|
||||
|
||||
|
||||
class LoginError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def web_login(server: str, username: str, password: str) -> tuple[str, str]:
|
||||
"""(API token, canonical user name) via the web login."""
|
||||
s = requests.Session()
|
||||
s.headers["User-Agent"] = api.USER_AGENT
|
||||
|
||||
def get(path):
|
||||
return s.get(server + path, headers={"Referer": server + path}, allow_redirects=False, timeout=30)
|
||||
|
||||
def post(path, form):
|
||||
return s.post(server + path, data=form, headers={"Referer": server + path, "Origin": server},
|
||||
allow_redirects=False, timeout=30)
|
||||
|
||||
try:
|
||||
page = get("/accounts/login/")
|
||||
m = _CSRF.search(page.text)
|
||||
if page.status_code != 200 or not m:
|
||||
raise LoginError(f"Login-Seite nicht lesbar ({page.status_code}).")
|
||||
r = post("/accounts/login/", {"csrfmiddlewaretoken": m.group(1), "username": username, "password": password})
|
||||
if r.status_code == 200:
|
||||
raise LoginError("Benutzername oder Passwort falsch.")
|
||||
if r.status_code != 302:
|
||||
raise LoginError(f"Anmeldung fehlgeschlagen ({r.status_code}).")
|
||||
try:
|
||||
t = _TOKEN.search(get("/accounts/settings/").text)
|
||||
if not t:
|
||||
raise LoginError("Kein API-Token gefunden. Melde dich einmal in einer diora-App an, dann noch mal.")
|
||||
name = _USERNAME.search(get("/").text)
|
||||
return t.group(1), html.unescape(name.group(1)) if name else username
|
||||
finally:
|
||||
c = _CSRF.search(get("/").text)
|
||||
if c:
|
||||
post("/accounts/logout/", {"csrfmiddlewaretoken": c.group(1)})
|
||||
except requests.RequestException as e:
|
||||
raise LoginError(f"Verbindung zu {server} fehlgeschlagen: {e}") from e
|
||||
|
||||
|
||||
def _books_open(server: str, token: str, key_b64: str) -> tuple[int, int]:
|
||||
"""(books, books the key opens)."""
|
||||
books, _, _ = api.fetch_books(server, token)
|
||||
ok = 0
|
||||
for b in books:
|
||||
try:
|
||||
crypto.decrypt(key_b64, b["meta_iv"], b["meta_ct"])
|
||||
ok += 1
|
||||
except crypto.DecryptError:
|
||||
pass
|
||||
return len(books), ok
|
||||
|
||||
|
||||
def login(server: str | None = None, *, paste_key: bool = False) -> RemoteConfig:
|
||||
server = (server or DEFAULT_SERVER).rstrip("/")
|
||||
username = input("diora-Benutzername: ").strip()
|
||||
password = getpass.getpass("Passwort (wird nicht gespeichert, nicht angezeigt): ")
|
||||
if not username or not password:
|
||||
raise LoginError("Abgebrochen.")
|
||||
token, username = web_login(server, username, password)
|
||||
key = "" if paste_key else crypto.derive_key_b64(username, password)
|
||||
del password
|
||||
|
||||
if key:
|
||||
total, ok = _books_open(server, token, key)
|
||||
if total and not ok:
|
||||
print("Der Schlüssel aus dem Passwort öffnet die Bücher nicht (Passwort seit dem Hochladen geändert?).")
|
||||
key = ""
|
||||
if not key:
|
||||
key = getpass.getpass("Schlüssel einfügen (diora: Einstellungen → Schlüssel kopieren, Base64; leer = abbrechen): ").strip()
|
||||
if not key:
|
||||
raise LoginError("Abgebrochen, nichts gespeichert.")
|
||||
try:
|
||||
if len(base64.b64decode(key)) != 32:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise LoginError("Das ist kein gültiger Schlüssel (32 Byte, Base64).") from None
|
||||
total, ok = _books_open(server, token, key)
|
||||
if total and not ok:
|
||||
raise LoginError("Auch dieser Schlüssel passt nicht. Nichts gespeichert.")
|
||||
print(f"Angemeldet als {username} ({total} Bücher).")
|
||||
return RemoteConfig(server_url=server, api_token=token, enc_key_b64=key, username=username)
|
||||
164
tui/diora_tui/marks.py
Normal file
164
tui/diora_tui/marks.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""Highlights in the terminal text: from the web's anchors to spans of the displayed text, and
|
||||
back from a selection to an anchor.
|
||||
|
||||
An anchor addresses characters of a block's *raw* text (every text node, whitespace as in the
|
||||
file); the TUI shows the block with whitespace collapsed (blocks.collapse_map relates the two).
|
||||
The raw text is read from the file for the blocks involved only, a chapter at a time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from rich.style import Style
|
||||
|
||||
from . import annotations, blocks
|
||||
|
||||
SELECTION_STYLE = Style(reverse=True)
|
||||
NOTE_STYLE = Style(underline=True, dim=False)
|
||||
_WORD_RE = re.compile(r"\S+")
|
||||
|
||||
|
||||
def highlight_style(color: str | None) -> Style:
|
||||
return Style(color="black", bgcolor=annotations.COLORS.get(color or "yellow", annotations.COLORS["yellow"]))
|
||||
|
||||
|
||||
# ---- words (the cursor moves by word)
|
||||
|
||||
def word_starts(text: str) -> list[int]:
|
||||
return [m.start() for m in _WORD_RE.finditer(text)]
|
||||
|
||||
|
||||
def word_at(text: str, pos: int) -> tuple[int, int] | None:
|
||||
"""(start, end) of the word at or after `pos`, or None if there is none."""
|
||||
for m in _WORD_RE.finditer(text):
|
||||
if m.end() > pos:
|
||||
return m.start(), m.end()
|
||||
return None
|
||||
|
||||
|
||||
def next_word(text: str, pos: int) -> int | None:
|
||||
return next((s for s in word_starts(text) if s > pos), None)
|
||||
|
||||
|
||||
def prev_word(text: str, pos: int) -> int | None:
|
||||
return next((s for s in reversed(word_starts(text)) if s < pos), None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Maps:
|
||||
raw: str
|
||||
norm_of_raw: list[int]
|
||||
raw_of_norm: list[int]
|
||||
length: int
|
||||
|
||||
|
||||
class MarkIndex:
|
||||
"""Relates a book's displayed blocks to the raw text anchors count in."""
|
||||
|
||||
def __init__(self, book: blocks.FlatBook) -> None:
|
||||
self.book = book
|
||||
self._raws: dict[int, list[str]] = {} # chapter -> raw text of its blocks
|
||||
self._maps: dict[int, Maps | None] = {}
|
||||
|
||||
def maps(self, block_index: int) -> Maps | None:
|
||||
"""None when the displayed text isn't the plain collapse of the raw text (a footnote
|
||||
marker with spaces inside): such a block can't be addressed exactly."""
|
||||
if block_index in self._maps:
|
||||
return self._maps[block_index]
|
||||
b = self.book.blocks[block_index]
|
||||
chapter = b.chapter_index
|
||||
if chapter not in self._raws:
|
||||
self._raws[chapter] = blocks.raw_block_texts(self.book.path, chapter)
|
||||
first = self.book.chapter_start_block[chapter]
|
||||
raws = self._raws[chapter]
|
||||
result = None
|
||||
if 0 <= block_index - first < len(raws):
|
||||
raw = raws[block_index - first]
|
||||
norm_of_raw, raw_of_norm, length = blocks.collapse_map(raw)
|
||||
if length == len(b.text) and "".join(raw[i] if not blocks._JS_SPACE_RE.fullmatch(raw[i]) else " " for i in raw_of_norm) == b.text:
|
||||
result = Maps(raw, norm_of_raw, raw_of_norm, length)
|
||||
self._maps[block_index] = result
|
||||
return result
|
||||
|
||||
# ---- annotation -> spans
|
||||
|
||||
def spans(self, items: list[dict]) -> dict[int, list[tuple[int, int, Style]]]:
|
||||
"""Per block the (start, end, style) spans of the highlights and notes."""
|
||||
out: dict[int, list[tuple[int, int, Style]]] = {}
|
||||
n = len(self.book.blocks)
|
||||
for h in items:
|
||||
a = h.get("anchor") or {}
|
||||
if "startBlockIndex" not in a: # an old anchor (XPath): the web falls back to the quote, so do we
|
||||
found = self._find_quote(a)
|
||||
if found is not None:
|
||||
bi, s0, e0 = found
|
||||
out.setdefault(bi, []).append((s0, e0, NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))))
|
||||
continue
|
||||
try:
|
||||
sb = int(a["startBlockIndex"])
|
||||
eb = int(a.get("endBlockIndex", sb))
|
||||
sc, ec = int(a.get("startChar", 0)), int(a.get("endChar", 0))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not (0 <= sb < n and sb <= eb < n):
|
||||
continue
|
||||
style = NOTE_STYLE if h.get("type") == "note" else highlight_style(h.get("color"))
|
||||
for bi, s, e in self._ranges(sb, sc, eb, ec, a.get("quote") or ""):
|
||||
if e > s:
|
||||
out.setdefault(bi, []).append((s, e, style))
|
||||
return out
|
||||
|
||||
def _find_quote(self, anchor: dict) -> tuple[int, int, int] | None:
|
||||
"""(block, start, end) of the first place in the anchor's chapter that holds its quote."""
|
||||
needle = " ".join((anchor.get("quote") or "").split())
|
||||
src = anchor.get("chapterSrc")
|
||||
if not needle or src not in self.book.chapter_srcs:
|
||||
return None
|
||||
chapter = self.book.chapter_srcs.index(src)
|
||||
first = self.book.chapter_start_block[chapter]
|
||||
last = self.book.chapter_start_block[chapter + 1] if chapter + 1 < len(self.book.chapter_start_block) else len(self.book.blocks)
|
||||
for bi in range(first, last):
|
||||
i = self.book.blocks[bi].text.find(needle)
|
||||
if i >= 0:
|
||||
return bi, i, i + len(needle)
|
||||
return None
|
||||
|
||||
def _ranges(self, sb: int, sc: int, eb: int, ec: int, quote: str):
|
||||
text = self.book.blocks[sb].text
|
||||
m = self.maps(sb)
|
||||
if m is None or (eb != sb and self.maps(eb) is None):
|
||||
# not addressable by offsets: find the quote
|
||||
i = text.find(" ".join(quote.split())) if quote else -1
|
||||
if i >= 0:
|
||||
yield sb, i, i + len(" ".join(quote.split()))
|
||||
return
|
||||
s = m.norm_of_raw[min(sc, len(m.raw))]
|
||||
if sb == eb:
|
||||
yield sb, s, m.norm_of_raw[min(ec, len(m.raw))]
|
||||
return
|
||||
yield sb, s, m.length
|
||||
for bi in range(sb + 1, eb):
|
||||
yield bi, 0, len(self.book.blocks[bi].text)
|
||||
me = self.maps(eb)
|
||||
yield eb, 0, me.norm_of_raw[min(ec, len(me.raw))]
|
||||
|
||||
# ---- selection -> anchor
|
||||
|
||||
def anchor(self, start: tuple[int, int], end: tuple[int, int]) -> dict | None:
|
||||
"""The web anchor for the displayed range start..end ((block, char) each, end exclusive),
|
||||
or None when a block can't be addressed exactly."""
|
||||
(sb, sc), (eb, ec) = start, end
|
||||
ms, me = self.maps(sb), self.maps(eb)
|
||||
if ms is None or me is None:
|
||||
return None
|
||||
s_raw = ms.raw_of_norm[sc] if sc < ms.length else len(ms.raw)
|
||||
e_raw = me.raw_of_norm[ec - 1] + 1 if ec > 0 else 0
|
||||
if sb == eb:
|
||||
quote = ms.raw[s_raw:e_raw]
|
||||
else:
|
||||
quote = ms.raw[s_raw:] + me.raw[:e_raw]
|
||||
chapter_src = self.book.chapter_srcs[self.book.blocks[sb].chapter_index] if self.book.chapter_srcs else ""
|
||||
return annotations.make_anchor(chapter_src, sb, s_raw, eb, e_raw, quote)
|
||||
30
tui/diora_tui/paths.py
Normal file
30
tui/diora_tui/paths.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Where the TUI keeps its files. `DIORA_TUI_HOME` moves all of it into one folder (tests, the
|
||||
smoke script and anyone who wants a second account side by side use it); platformdirs' own
|
||||
answer on Windows doesn't follow the LOCALAPPDATA/APPDATA environment variables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_cache_dir, user_config_dir, user_data_dir
|
||||
|
||||
|
||||
def _home() -> Path | None:
|
||||
h = os.environ.get("DIORA_TUI_HOME")
|
||||
return Path(h) if h else None
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
h = _home()
|
||||
return h / "data" if h else Path(user_data_dir("diora-tui", "diora"))
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
h = _home()
|
||||
return h / "config" if h else Path(user_config_dir("diora-tui", "diora"))
|
||||
|
||||
|
||||
def cache_dir() -> Path:
|
||||
h = _home()
|
||||
return h / "cache" if h else Path(user_cache_dir("diora-tui", "diora"))
|
||||
|
|
@ -1,68 +1,231 @@
|
|||
"""Local reading-progress storage.
|
||||
"""Reading position per book: the place last read wins.
|
||||
|
||||
Positions are stored as the same "blockIndex:innerFraction" anchor string the
|
||||
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.
|
||||
This is the contract in diora-web docs/progress-sync.md, the same rules the server
|
||||
(books/views.py save_progress) and the other clients apply (diora-android Ops.kt,
|
||||
diora-desktop src/main/ops.js):
|
||||
|
||||
* A position carries `read_at`, the moment the reader arrived there. Local times are the
|
||||
server's clock: `offset` (server - this machine, ms, learnt from the server's clock on every
|
||||
answer) turns this machine's `read_at` into it. Saving an unchanged position again carries the
|
||||
old time, so an idle reader can never beat a newer position from elsewhere.
|
||||
* A write is stored when its time is >= the stored one (`force`: always, time = now). The
|
||||
furthest place ever reported only grows.
|
||||
* A write is also queued (`pending`, one per book: the later `read_at` wins, `force` sticks)
|
||||
until the server has it. After a pull the pending write is applied again on top of the
|
||||
server's state with the same rule.
|
||||
|
||||
State lives in progress.json next to the book list; the file also holds the clock offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import functools
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_data_dir
|
||||
from . import paths
|
||||
from .blocks import parse_anchor
|
||||
|
||||
from .blocks import anchor_is_further
|
||||
DEVICE_NAME = "diora TUI"
|
||||
|
||||
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||
_DATA_DIR = paths.data_dir()
|
||||
_PROGRESS_FILE = _DATA_DIR / "progress.json"
|
||||
_VERSION = 2
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def plausible_ms(v) -> int | None:
|
||||
"""A time (ms since epoch) from the server or a body, or None (as Ops.ms)."""
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v in (float("inf"), float("-inf")):
|
||||
return None
|
||||
t = int(v)
|
||||
return t if 10**12 <= t < 10**14 else None
|
||||
|
||||
|
||||
def cmp_progress(a_anchor: str, a_frac: float, b_anchor: str, b_frac: float) -> float:
|
||||
"""> 0 if A is further into the book than B, < 0 if behind (books/views.py _cmp_progress)."""
|
||||
a, b = parse_anchor(a_anchor or ""), parse_anchor(b_anchor or "")
|
||||
if a is not None and b is not None:
|
||||
return (a[0] - b[0]) if a[0] != b[0] else a[1] - b[1]
|
||||
return a_frac - b_frac
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
|
||||
updated_at: float = 0.0
|
||||
class BookState:
|
||||
anchor: str = ""
|
||||
fraction: float = 0.0
|
||||
read_at: int | None = None # server clock, ms
|
||||
device: str = ""
|
||||
furthest_anchor: str = ""
|
||||
furthest_fraction: float = 0.0
|
||||
# The write the server hasn't got yet: {"anchor", "fraction", "read_at" (this machine's
|
||||
# clock), "force"}.
|
||||
pending: dict | None = None
|
||||
# The server's read_at for the last write of ours that it stored: tells our own position
|
||||
# coming back from a pull from another device's.
|
||||
echo_read_at: int | None = None
|
||||
|
||||
|
||||
def _load_all() -> dict[str, dict]:
|
||||
if not _PROGRESS_FILE.exists():
|
||||
return {}
|
||||
def _locked(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(self, *a, **kw):
|
||||
with self._lock:
|
||||
return fn(self, *a, **kw)
|
||||
return wrapper
|
||||
|
||||
|
||||
class ProgressStore:
|
||||
"""Shared by the UI thread (the reader writes) and the sync workers (they pull and push)."""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or _PROGRESS_FILE
|
||||
self._lock = threading.RLock()
|
||||
self.offset = 0
|
||||
self.books: dict[str, BookState] = {}
|
||||
self._load()
|
||||
|
||||
# ---- persistence
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
return json.loads(_PROGRESS_FILE.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
raw = json.loads(self.path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
if not isinstance(raw, dict):
|
||||
return
|
||||
if raw.get("version") == _VERSION:
|
||||
self.offset = int(raw.get("offset") or 0)
|
||||
for key, entry in (raw.get("books") or {}).items():
|
||||
try:
|
||||
self.books[key] = BookState(**entry)
|
||||
except TypeError:
|
||||
continue
|
||||
return
|
||||
# version 1: {book_id: {anchor, updated_at (s)}}, furthest-wins, nothing queued
|
||||
for key, entry in raw.items():
|
||||
if isinstance(entry, dict) and entry.get("anchor"):
|
||||
self.books[key] = BookState(
|
||||
anchor=entry["anchor"], read_at=int(entry.get("updated_at", 0) * 1000) or None,
|
||||
furthest_anchor=entry["anchor"],
|
||||
)
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {"version": _VERSION, "offset": self.offset,
|
||||
"books": {k: asdict(v) for k, v in self.books.items()}}
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=1))
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
# ---- clock
|
||||
|
||||
@_locked
|
||||
def set_server_now(self, server_now: int | None, device_now: int | None = None) -> None:
|
||||
t = plausible_ms(server_now)
|
||||
if t is not None:
|
||||
self.offset = t - (device_now if device_now is not None else now_ms())
|
||||
|
||||
# ---- reads
|
||||
|
||||
@_locked
|
||||
def get(self, key: str) -> BookState | None:
|
||||
"""A copy: the sync workers change the live state."""
|
||||
st = self.books.get(key)
|
||||
return replace(st, pending=dict(st.pending) if st.pending else None) if st else None
|
||||
|
||||
@_locked
|
||||
def pending_keys(self) -> list[str]:
|
||||
return [k for k, v in self.books.items() if v.pending]
|
||||
|
||||
# ---- writes
|
||||
|
||||
@_locked
|
||||
def write(self, key: str, anchor: str, fraction: float, read_at: int, *, force: bool = False,
|
||||
queue: bool = True, device: str = DEVICE_NAME, now: int | None = None) -> bool:
|
||||
"""Apply a position the reader reached (`read_at`: this machine's clock) locally and
|
||||
queue it for the server (`queue` False: the book isn't one of the server's). Returns
|
||||
whether it is the continue point now."""
|
||||
st = self.books.setdefault(key, BookState())
|
||||
fraction = max(0.0, min(1.0, fraction))
|
||||
took = self._apply(st, anchor, fraction, read_at, force, device, now)
|
||||
if queue:
|
||||
new = {"anchor": anchor, "fraction": fraction, "read_at": read_at, "force": force}
|
||||
st.pending = _merge_pending(st.pending, new)
|
||||
self.save()
|
||||
return took
|
||||
|
||||
def _apply(self, st: BookState, anchor: str, fraction: float, read_at: int | None, force: bool,
|
||||
device: str, now: int | None) -> bool:
|
||||
now_server = (now if now is not None else now_ms()) + self.offset
|
||||
if force:
|
||||
when, take = now_server, True
|
||||
else:
|
||||
when = min((read_at or 0) + self.offset, now_server)
|
||||
take = st.read_at is None or when >= st.read_at
|
||||
if take:
|
||||
st.anchor, st.fraction, st.read_at, st.device = anchor, fraction, when, device[:40]
|
||||
if cmp_progress(anchor, fraction, st.furthest_anchor, st.furthest_fraction) > 0:
|
||||
st.furthest_anchor, st.furthest_fraction = anchor, fraction
|
||||
return take
|
||||
|
||||
@_locked
|
||||
def apply_server(self, key: str, entry: dict, *, keep_pending: bool = True) -> None:
|
||||
"""Take the server's state for the book (an entry of /books/ or a progress answer), then
|
||||
apply the pending write on top by the same rule."""
|
||||
st = self.books.setdefault(key, BookState())
|
||||
st.anchor = entry.get("position_anchor") or ""
|
||||
st.fraction = float(entry.get("scroll_fraction") or 0.0)
|
||||
st.read_at = plausible_ms(entry.get("read_at"))
|
||||
st.device = entry.get("device") or ""
|
||||
st.furthest_anchor = entry.get("furthest_anchor") or ""
|
||||
st.furthest_fraction = float(entry.get("furthest_fraction") or 0.0)
|
||||
if st.pending and keep_pending:
|
||||
p = st.pending
|
||||
self._apply(st, p["anchor"], p["fraction"], p["read_at"], bool(p.get("force")), DEVICE_NAME, None)
|
||||
self.save()
|
||||
|
||||
@_locked
|
||||
def sent(self, key: str, body: dict, answer: dict | None) -> None:
|
||||
"""The server took `body` (the pending write as it was sent): forget it unless a newer one
|
||||
queued meanwhile, and take the server's state from the answer."""
|
||||
st = self.books.get(key)
|
||||
if st is None:
|
||||
return
|
||||
if st.pending and st.pending.get("read_at") == body.get("read_at") and bool(st.pending.get("force")) == bool(body.get("force")):
|
||||
st.pending = None
|
||||
if answer:
|
||||
self.set_server_now(answer.get("server_now"))
|
||||
if answer.get("stored") is True:
|
||||
st.echo_read_at = plausible_ms(answer.get("read_at"))
|
||||
self.apply_server(key, answer)
|
||||
else:
|
||||
self.save()
|
||||
|
||||
|
||||
def _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 _merge_pending(old: dict | None, new: dict) -> dict:
|
||||
"""Two queued writes for the book: the place read later is the one to send; a forced place
|
||||
stays forced (Ops.merge)."""
|
||||
if old is None:
|
||||
return new
|
||||
keep = new if new["read_at"] >= old["read_at"] else old
|
||||
keep = dict(keep)
|
||||
if old.get("force") or new.get("force"):
|
||||
keep["force"] = True
|
||||
return keep
|
||||
|
||||
|
||||
def 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))
|
||||
_default: ProgressStore | None = None
|
||||
|
||||
|
||||
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
|
||||
def default() -> ProgressStore:
|
||||
global _default
|
||||
if _default is None:
|
||||
_default = ProgressStore()
|
||||
return _default
|
||||
|
|
|
|||
|
|
@ -7,12 +7,19 @@ Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the
|
|||
whole book into one Static: for large books (tens of thousands of blocks) a
|
||||
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.
|
||||
|
||||
Reading position (docs/progress-sync.md, rules in progress.py): the place last read wins.
|
||||
The reader saves a position only when the *reader* moved it (keys, wheel), stamped with the
|
||||
moment of arriving; restoring, re-wrapping on resize and jumping on an offer change nothing.
|
||||
While a book is open the server is asked now and then; a newer position from another device
|
||||
is offered ("Auf X weitergelesen — springen?"), never applied by itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
|
@ -23,29 +30,28 @@ 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 . import annotations, blocks, cache, config as config_mod, epub, layout, library, library_meta, marks, progress, remote
|
||||
from .annotation_screens import AnnotationsScreen, NoteScreen
|
||||
from .book_view import ContinuousBookView
|
||||
from .statusbar import StatusBar
|
||||
|
||||
MAX_LINE_WIDTH = 120
|
||||
AUTOSAVE_INTERVAL = 5.0
|
||||
# How often the server is asked for a newer position while the book is open (the apps pull
|
||||
# at most every 2 minutes).
|
||||
REMOTE_CHECK_INTERVAL = 120.0
|
||||
# "Weiteste Stelle" is offered when the furthest place is this much of the book ahead.
|
||||
FURTHEST_OFFER_SHARE = 0.01
|
||||
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
|
||||
# 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
|
||||
# A selection may span this many paragraphs (each is drawn again at every cursor move).
|
||||
MAX_MARK_BLOCKS = 200
|
||||
|
||||
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
|
||||
|
||||
# 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")]
|
||||
|
|
@ -64,6 +70,27 @@ class FootnoteScreen(ModalScreen[None]):
|
|||
self.dismiss(None)
|
||||
|
||||
|
||||
class JumpOfferScreen(ModalScreen[bool]):
|
||||
"""A question the reader answers with a key; nothing moves unless the answer is yes."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("y,enter", "answer(True)", "Springen"),
|
||||
Binding("n,escape,q", "answer(False)", "Hier bleiben"),
|
||||
]
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__()
|
||||
self._text = text
|
||||
|
||||
def compose(self):
|
||||
with Container(id="offer-body"):
|
||||
yield Static(self._text)
|
||||
yield Label("y/Enter: springen n/Escape: hier bleiben", id="offer-hint")
|
||||
|
||||
def action_answer(self, yes: bool) -> None:
|
||||
self.dismiss(yes)
|
||||
|
||||
|
||||
class ReaderScreen(Screen):
|
||||
BINDINGS = [
|
||||
Binding("j,down", "scroll_down_line", "Runter", show=False),
|
||||
|
|
@ -71,6 +98,13 @@ class ReaderScreen(Screen):
|
|||
Binding("n", "next_chapter", "Nächstes Kapitel"),
|
||||
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
|
||||
Binding("f", "peek_footnote", "Fußnote"),
|
||||
Binding("w", "goto_furthest", "Weiteste Stelle"),
|
||||
Binding("v", "select", "Markieren"),
|
||||
Binding("b", "bookmark", "Lesezeichen"),
|
||||
Binding("a", "annotations", "Markierungen"),
|
||||
Binding("h,left", "word_back", "Wort zurück", show=False),
|
||||
Binding("l,right", "word_forward", "Wort vor", show=False),
|
||||
Binding("enter,space", "confirm", "Weiter", show=False),
|
||||
Binding("q,escape", "back", "Zurück zur Bibliothek"),
|
||||
]
|
||||
|
||||
|
|
@ -79,9 +113,24 @@ class ReaderScreen(Screen):
|
|||
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 = ""
|
||||
self._key = epub._book_id(path)
|
||||
self._server_id = epub.server_id_of(path)
|
||||
self._cfg = config_mod.load() if self._server_id is not None else None
|
||||
self._store = progress.default()
|
||||
# The position the view is on, as last saved or restored, and when the reader last
|
||||
# moved it (this machine's clock). A change of the anchor is a reading position only
|
||||
# when there is such a move.
|
||||
self._last_anchor: str | None = None
|
||||
self._last_move_ms: int | None = None
|
||||
# read_at (server clock) of the state this view is based on: a newer one from the
|
||||
# server is what gets offered.
|
||||
self._held_read_at: int | None = None
|
||||
self._offer_open = False
|
||||
self._marks: marks.MarkIndex | None = None
|
||||
self._ann = annotations.default()
|
||||
self._styled: set[int] = set() # blocks drawn with marks at the moment
|
||||
self._sel: dict | None = None # the selection being made: phase, cursor, start
|
||||
self._ann_timer = None
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
|
@ -93,7 +142,7 @@ class ReaderScreen(Screen):
|
|||
# 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)
|
||||
self._book_view = ContinuousBookView(_EMPTY_LAYOUT, on_read_scroll=self._on_read_scroll)
|
||||
yield self._book_view
|
||||
yield StatusBar()
|
||||
yield Footer()
|
||||
|
|
@ -129,8 +178,17 @@ class ReaderScreen(Screen):
|
|||
self.sub_title = book.title
|
||||
self._apply_layout(book_layout, width)
|
||||
self.query_one("#reader-loading", LoadingIndicator).display = False
|
||||
library_meta.set_local_pages(self._key, library.pages_of_chars(book.total_chars))
|
||||
st = self._store.get(self._key)
|
||||
self._held_read_at = st.read_at if st else None
|
||||
self._marks = marks.MarkIndex(book)
|
||||
self._apply_marks()
|
||||
if self._cfg is not None:
|
||||
self._pull_annotations()
|
||||
self.call_after_refresh(self._restore_position)
|
||||
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
|
||||
if self._cfg is not None:
|
||||
self.set_interval(REMOTE_CHECK_INTERVAL, self._check_remote)
|
||||
|
||||
def _content_width(self) -> int:
|
||||
# Measuring the *container* rather than book_view's own size avoids a
|
||||
|
|
@ -160,6 +218,8 @@ class ReaderScreen(Screen):
|
|||
new_layout = layout.build_layout(block_texts, width, console)
|
||||
cache.save(self.path, width, self.book, new_layout)
|
||||
self._apply_layout(new_layout, width)
|
||||
self._styled = set() # a fresh layout is drawn without marks
|
||||
self._apply_marks()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
if self.book is None:
|
||||
|
|
@ -168,21 +228,31 @@ class ReaderScreen(Screen):
|
|||
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)
|
||||
with self._book_view.program_scroll():
|
||||
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
|
||||
|
||||
def _scroll_to_anchor(self, anchor: str) -> bool:
|
||||
parsed = blocks.parse_anchor(anchor)
|
||||
if parsed is None:
|
||||
return False
|
||||
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
|
||||
with self._book_view.program_scroll():
|
||||
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||
return y > 0
|
||||
|
||||
def _restore_position(self, attempt: int = 0) -> None:
|
||||
if self.book is None:
|
||||
return
|
||||
saved = progress.load(self.book.id)
|
||||
if saved is None:
|
||||
st = self._store.get(self._key)
|
||||
if st is None or not st.anchor or blocks.parse_anchor(st.anchor) is None:
|
||||
self._settle()
|
||||
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)
|
||||
parsed = blocks.parse_anchor(st.anchor)
|
||||
y = layout.scroll_y_for_anchor(self.book_layout, *parsed)
|
||||
if y <= 0:
|
||||
self._settle()
|
||||
return
|
||||
with self._book_view.program_scroll():
|
||||
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||
# A widget that's only just become part of the layout doesn't always
|
||||
# honor an immediate scroll on the first attempt (its own size/scroll
|
||||
|
|
@ -190,46 +260,155 @@ class ReaderScreen(Screen):
|
|||
# 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))
|
||||
return
|
||||
self._settle()
|
||||
self._offer_furthest(st)
|
||||
|
||||
def _settle(self) -> None:
|
||||
"""The view is where the saved position put it: from here a changed anchor with a move
|
||||
by the reader is a reading position."""
|
||||
self._last_anchor = self._current_anchor_str()
|
||||
self._last_move_ms = None
|
||||
|
||||
def _current_anchor_str(self) -> str:
|
||||
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||
return blocks.format_anchor(block_index, inner_fraction)
|
||||
|
||||
# ---- saving
|
||||
|
||||
def _on_read_scroll(self) -> None:
|
||||
self._last_move_ms = progress.now_ms()
|
||||
|
||||
def _autosave(self) -> None:
|
||||
self._save_progress()
|
||||
|
||||
def _save_progress(self) -> None:
|
||||
if self.book is None or not self.book_layout.heights:
|
||||
if self.book is None or not self.book_layout.heights or self._last_anchor is None:
|
||||
return
|
||||
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)
|
||||
if anchor == self._last_anchor:
|
||||
self._last_move_ms = None # moved and came back: nothing new
|
||||
return
|
||||
moved_at = self._last_move_ms
|
||||
self._last_anchor = anchor
|
||||
self._last_move_ms = None
|
||||
if moved_at is None:
|
||||
return # the layout moved it, not the reader
|
||||
block_index, inner = blocks.parse_anchor(anchor)
|
||||
fraction = blocks.fraction_for_anchor(self.book, block_index, inner)
|
||||
self._store.write(self._key, anchor, fraction, moved_at, queue=self._cfg is not None)
|
||||
st = self._store.get(self._key)
|
||||
if st is not None:
|
||||
self._held_read_at = st.read_at
|
||||
if self._cfg is not None:
|
||||
self._push()
|
||||
|
||||
@work(thread=True, exclusive=True, group="progress-push")
|
||||
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
|
||||
def _push(self) -> None:
|
||||
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
|
||||
remote.flush_pending(self._cfg)
|
||||
except Exception:
|
||||
pass # best-effort — local progress is already saved regardless
|
||||
return # best-effort — the position is saved locally and stays queued
|
||||
self.app.call_from_thread(self._after_push)
|
||||
|
||||
def _after_push(self) -> None:
|
||||
# What the server stored of ours is no news; a newer position of another device in its
|
||||
# answer (our write was refused) is.
|
||||
st = self._store.get(self._key)
|
||||
if st is not None and st.echo_read_at is not None and st.echo_read_at >= (self._held_read_at or 0):
|
||||
self._held_read_at = st.echo_read_at
|
||||
self._offer_newer()
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._save_progress()
|
||||
if self._cfg is not None and (self._store.pending_keys() or self._ann.dirty_keys()):
|
||||
# The screen's workers die with it; the queue still has to go out.
|
||||
threading.Thread(target=self._flush_quietly, args=(self._cfg,), daemon=False).start()
|
||||
|
||||
@staticmethod
|
||||
def _flush_quietly(cfg) -> None:
|
||||
for send in (remote.flush_pending, remote.push_annotations):
|
||||
try:
|
||||
send(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- the server's newer positions
|
||||
|
||||
@work(thread=True, exclusive=True, group="progress-check")
|
||||
def _check_remote(self) -> None:
|
||||
try:
|
||||
remote.sync_library(self.path.parent, self._cfg)
|
||||
except Exception:
|
||||
return # offline etc.: ask again later
|
||||
self.app.call_from_thread(self._offer_newer)
|
||||
|
||||
def _offer_newer(self) -> None:
|
||||
if self.book is None or self._offer_open or self._last_anchor is None:
|
||||
return
|
||||
st = self._store.get(self._key)
|
||||
if st is None or not st.anchor or st.read_at is None:
|
||||
return
|
||||
if self._held_read_at is not None and st.read_at <= self._held_read_at:
|
||||
return
|
||||
parsed = blocks.parse_anchor(st.anchor)
|
||||
if parsed is None:
|
||||
return
|
||||
shown_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||
self._held_read_at = st.read_at # asked once per newer state
|
||||
if parsed[0] == shown_block:
|
||||
return
|
||||
pct = round(blocks.fraction_for_anchor(self.book, *parsed) * 100)
|
||||
who = st.device or "einem anderen Gerät"
|
||||
self._ask(f"Auf {who} weitergelesen — bei {pct} %.\nSpringen?", st.anchor)
|
||||
|
||||
def _offer_furthest(self, st: progress.BookState) -> None:
|
||||
if self.book is None or not st.furthest_anchor:
|
||||
return
|
||||
here, there = blocks.parse_anchor(st.anchor), blocks.parse_anchor(st.furthest_anchor)
|
||||
if there is None:
|
||||
return
|
||||
share = blocks.fraction_for_anchor(self.book, *there) - (blocks.fraction_for_anchor(self.book, *here) if here else 0.0)
|
||||
if share >= FURTHEST_OFFER_SHARE:
|
||||
pct = round(blocks.fraction_for_anchor(self.book, *there) * 100)
|
||||
self.notify(f"Weiteste Stelle: {pct} % — mit w springen.", timeout=6)
|
||||
|
||||
def _ask(self, text: str, anchor: str) -> None:
|
||||
self._offer_open = True
|
||||
|
||||
def answered(yes: bool | None) -> None:
|
||||
self._offer_open = False
|
||||
if yes:
|
||||
self._scroll_to_anchor(anchor)
|
||||
self._last_anchor = self._current_anchor_str()
|
||||
self._last_move_ms = None
|
||||
|
||||
self.app.push_screen(JumpOfferScreen(text), answered)
|
||||
|
||||
def action_goto_furthest(self) -> None:
|
||||
st = self._store.get(self._key)
|
||||
if self.book is None or st is None or not st.furthest_anchor:
|
||||
self.notify("Keine weiteste Stelle bekannt.", timeout=3)
|
||||
return
|
||||
if blocks.parse_anchor(st.furthest_anchor) is None:
|
||||
return
|
||||
self._scroll_to_anchor(st.furthest_anchor)
|
||||
# A jump, not reading: nothing is saved until the reader carries on from there.
|
||||
self._last_anchor = self._current_anchor_str()
|
||||
self._last_move_ms = None
|
||||
|
||||
# ---- navigation
|
||||
|
||||
def action_scroll_down_line(self) -> None:
|
||||
if self._sel is not None:
|
||||
self._move_block(1)
|
||||
return
|
||||
self._book_view.scroll_relative(y=1, animate=False)
|
||||
|
||||
def action_scroll_up_line(self) -> None:
|
||||
if self._sel is not None:
|
||||
self._move_block(-1)
|
||||
return
|
||||
self._book_view.scroll_relative(y=-1, animate=False)
|
||||
|
||||
def _current_chapter_index(self) -> int:
|
||||
|
|
@ -243,6 +422,9 @@ class ReaderScreen(Screen):
|
|||
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||
|
||||
def action_next_chapter(self) -> None:
|
||||
if self._sel is not None:
|
||||
self._selection_note() # n: a note, while marking
|
||||
return
|
||||
if self.book is None:
|
||||
return
|
||||
chapter = self._current_chapter_index()
|
||||
|
|
@ -283,4 +465,239 @@ class ReaderScreen(Screen):
|
|||
self.app.push_screen(FootnoteScreen(notes))
|
||||
|
||||
def action_back(self) -> None:
|
||||
if self._sel is not None:
|
||||
self._end_selection()
|
||||
return
|
||||
self.app.pop_screen()
|
||||
|
||||
# ---- marks on screen
|
||||
|
||||
def _marks_by_block(self) -> dict[int, list]:
|
||||
spans: dict[int, list] = {}
|
||||
if self._marks is not None:
|
||||
for bi, lst in self._marks.spans(self._ann.get(self._key).highlights).items():
|
||||
spans.setdefault(bi, []).extend(lst)
|
||||
for bi, lst in self._selection_spans().items():
|
||||
spans.setdefault(bi, []).extend(lst)
|
||||
return spans
|
||||
|
||||
def _apply_marks(self) -> None:
|
||||
"""Draw the highlights (and the selection) in place; blocks that lost theirs are drawn plain."""
|
||||
if self.book is None or not self.book_layout.heights:
|
||||
return
|
||||
spans = self._marks_by_block()
|
||||
console = Console(width=self.book_layout.width)
|
||||
for bi in sorted(self._styled | set(spans)):
|
||||
if 0 <= bi < len(self.book.blocks):
|
||||
layout.restyle_block(self.book_layout, bi, self.book.blocks[bi].text, console, spans.get(bi, ()))
|
||||
self._styled = set(spans)
|
||||
self._book_view.refresh()
|
||||
|
||||
@work(thread=True, exclusive=True, group="ann-pull")
|
||||
def _pull_annotations(self) -> None:
|
||||
if remote.pull_annotations(self._cfg, self._server_id, self._key):
|
||||
self.app.call_from_thread(self._apply_marks)
|
||||
|
||||
def _annotations_changed(self) -> None:
|
||||
"""A highlight, note or bookmark was made, changed or deleted: draw it, and send it soon."""
|
||||
self._apply_marks()
|
||||
if self._cfg is None:
|
||||
return
|
||||
if self._ann_timer is not None:
|
||||
self._ann_timer.stop()
|
||||
self._ann_timer = self.set_timer(2.0, self._push_annotations) # like the web's debounce
|
||||
|
||||
@work(thread=True, exclusive=True, group="ann-push")
|
||||
def _push_annotations(self) -> None:
|
||||
try:
|
||||
remote.push_annotations(self._cfg)
|
||||
except Exception:
|
||||
pass # stays queued
|
||||
|
||||
# ---- bookmarks and the list
|
||||
|
||||
def action_bookmark(self) -> None:
|
||||
if self.book is None or not self.book_layout.heights or self._sel is not None:
|
||||
return
|
||||
block, inner = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||
chapter = max(0, self._current_chapter_index())
|
||||
src = self.book.chapter_srcs[chapter] if chapter < len(self.book.chapter_srcs) else ""
|
||||
label = re.sub(r"\.x?html?$", "", src.rsplit("/", 1)[-1], flags=re.I) or "Lesezeichen" # as the web names it
|
||||
bm = annotations.new_bookmark(label, src, blocks.fraction_for_anchor(self.book, block, inner))
|
||||
self._ann.add(self._key, "bookmarks", bm)
|
||||
self.notify(f"★ Lesezeichen: {label}", timeout=2.5)
|
||||
self._annotations_changed()
|
||||
|
||||
def action_annotations(self) -> None:
|
||||
if self.book is None or self._sel is not None:
|
||||
return
|
||||
|
||||
def picked(block: int | None) -> None:
|
||||
if block is not None:
|
||||
self._jump_quiet(block)
|
||||
|
||||
self.app.push_screen(AnnotationsScreen(self.book, self._ann, self._key, self._annotations_changed, self._marks), picked)
|
||||
|
||||
def _jump_quiet(self, block: int) -> None:
|
||||
"""Go to a block as a jump, not as reading: nothing is saved until the reader moves on."""
|
||||
y = layout.scroll_y_for_anchor(self.book_layout, block, 0.0)
|
||||
with self._book_view.program_scroll():
|
||||
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||
self._last_anchor = self._current_anchor_str()
|
||||
self._last_move_ms = None
|
||||
|
||||
# ---- selecting text (v): a cursor by word, Enter sets the start, Enter again the end
|
||||
|
||||
def _first_block_in_view(self) -> int:
|
||||
y = self._book_view.scroll_y
|
||||
i = bisect.bisect_left(self.book_layout.starts, y)
|
||||
return min(i, len(self.book.blocks) - 1)
|
||||
|
||||
def _word_block(self, block: int, step: int) -> int | None:
|
||||
"""The nearest block from `block` (inclusive) in direction `step` that has a word."""
|
||||
while 0 <= block < len(self.book.blocks):
|
||||
if marks.word_starts(self.book.blocks[block].text):
|
||||
return block
|
||||
block += step
|
||||
return None
|
||||
|
||||
def action_select(self) -> None:
|
||||
if self.book is None or not self.book_layout.heights:
|
||||
return
|
||||
if self._sel is not None:
|
||||
self.action_confirm()
|
||||
return
|
||||
block = self._word_block(self._first_block_in_view(), 1)
|
||||
if block is None:
|
||||
return
|
||||
self._sel = {"phase": "start", "cursor": (block, marks.word_starts(self.book.blocks[block].text)[0]), "start": None}
|
||||
self.notify("Markieren: h/l Wort, j/k Absatz, Enter = Anfang, n = Notiz, Esc = Abbrechen", timeout=6)
|
||||
self._selection_moved()
|
||||
|
||||
def _end_selection(self) -> None:
|
||||
self._sel = None
|
||||
self._apply_marks()
|
||||
|
||||
def _selection_moved(self) -> None:
|
||||
self._apply_marks()
|
||||
block, char = self._sel["cursor"]
|
||||
text = self.book.blocks[block].text
|
||||
row = self.book_layout.starts[block] + int(char / max(1, len(text)) * self.book_layout.heights[block])
|
||||
top, height = self._book_view.scroll_y, self._book_view.size.height
|
||||
if not (top <= row < top + height - 1):
|
||||
with self._book_view.program_scroll():
|
||||
self._book_view.scroll_to(y=max(0, row - height // 3), animate=False, immediate=True)
|
||||
self._last_anchor = self._current_anchor_str()
|
||||
|
||||
def action_word_forward(self) -> None:
|
||||
self._move_word(1)
|
||||
|
||||
def action_word_back(self) -> None:
|
||||
self._move_word(-1)
|
||||
|
||||
def _move_word(self, step: int) -> None:
|
||||
if self._sel is None:
|
||||
return
|
||||
block, char = self._sel["cursor"]
|
||||
text = self.book.blocks[block].text
|
||||
target = marks.next_word(text, char) if step > 0 else marks.prev_word(text, char)
|
||||
if target is not None:
|
||||
self._sel["cursor"] = (block, target)
|
||||
else:
|
||||
nb = self._word_block(block + step, step)
|
||||
if nb is None:
|
||||
return
|
||||
ws = marks.word_starts(self.book.blocks[nb].text)
|
||||
self._sel["cursor"] = (nb, ws[0] if step > 0 else ws[-1])
|
||||
self._selection_moved()
|
||||
|
||||
def _move_block(self, step: int) -> None:
|
||||
nb = self._word_block(self._sel["cursor"][0] + step, step)
|
||||
if nb is not None:
|
||||
self._sel["cursor"] = (nb, marks.word_starts(self.book.blocks[nb].text)[0])
|
||||
self._selection_moved()
|
||||
|
||||
def _cursor_word(self) -> tuple[int, int, int]:
|
||||
block, char = self._sel["cursor"]
|
||||
w = marks.word_at(self.book.blocks[block].text, char) or (char, char)
|
||||
return block, w[0], w[1]
|
||||
|
||||
def _selection_range(self) -> tuple[tuple[int, int], tuple[int, int]]:
|
||||
"""(start, end) of what is selected, end exclusive, whichever way the cursor went."""
|
||||
sb, sc = self._sel["start"]
|
||||
sw = marks.word_at(self.book.blocks[sb].text, sc) or (sc, sc)
|
||||
cb, cs, ce = self._cursor_word()
|
||||
if (cb, cs) >= (sb, sw[0]):
|
||||
return (sb, sw[0]), (cb, ce)
|
||||
return (cb, cs), (sb, sw[1])
|
||||
|
||||
def _selection_spans(self) -> dict[int, list]:
|
||||
if self._sel is None or self.book is None:
|
||||
return {}
|
||||
if self._sel["phase"] == "start":
|
||||
block, s, e = self._cursor_word()
|
||||
return {block: [(s, e, marks.SELECTION_STYLE)]}
|
||||
(sb, sc), (eb, ec) = self._selection_range()
|
||||
out: dict[int, list] = {}
|
||||
for bi in range(sb, eb + 1):
|
||||
text_len = len(self.book.blocks[bi].text)
|
||||
out[bi] = [(sc if bi == sb else 0, ec if bi == eb else text_len, marks.SELECTION_STYLE)]
|
||||
return out
|
||||
|
||||
def action_confirm(self) -> None:
|
||||
if self._sel is None:
|
||||
return
|
||||
if self._sel["phase"] == "start":
|
||||
block, s, _e = self._cursor_word()
|
||||
self._sel["start"] = (block, s)
|
||||
self._sel["phase"] = "end"
|
||||
self.notify("Ende wählen (h/l/j/k), Enter = fertig.", timeout=4)
|
||||
self._selection_moved()
|
||||
return
|
||||
(sb, sc), (eb, ec) = self._selection_range()
|
||||
if eb - sb > MAX_MARK_BLOCKS:
|
||||
self.notify(f"Zu lang zum Markieren (mehr als {MAX_MARK_BLOCKS} Absätze).", severity="warning")
|
||||
return
|
||||
anchor = self._marks.anchor((sb, sc), (eb, ec))
|
||||
if anchor is None:
|
||||
self.notify("Diese Stelle lässt sich nicht genau zuordnen (Fußnotenmarker mit Leerraum).", severity="warning")
|
||||
return
|
||||
names = [annotations.COLOR_NAMES[c] for c in annotations.COLORS] + ["Gelb mit Notiz …"]
|
||||
colors = list(annotations.COLORS)
|
||||
|
||||
def picked(i: int | None) -> None:
|
||||
if i is None:
|
||||
return # the selection stays: pick again or Esc
|
||||
if i < len(colors):
|
||||
self._add_highlight(anchor, colors[i], "")
|
||||
else:
|
||||
self.app.push_screen(NoteScreen("Notiz zur Markierung"), lambda text: self._add_highlight(anchor, "yellow", text) if text is not None else None)
|
||||
|
||||
from .library_screen import ChoiceScreen
|
||||
|
||||
self.app.push_screen(ChoiceScreen("Markieren als", names, 0), picked)
|
||||
|
||||
def _add_highlight(self, anchor: dict, color: str, note: str) -> None:
|
||||
self._ann.add(self._key, "highlights", annotations.new_highlight(anchor, color, note))
|
||||
self._sel = None
|
||||
self._annotations_changed()
|
||||
|
||||
def _selection_note(self) -> None:
|
||||
"""n while marking: a freeform note at the cursor's word (phase 1) or on the selection."""
|
||||
if self._sel["phase"] == "start":
|
||||
block, s, e = self._cursor_word()
|
||||
start, end = (block, s), (block, e)
|
||||
else:
|
||||
start, end = self._selection_range()
|
||||
anchor = self._marks.anchor(start, end)
|
||||
if anchor is None:
|
||||
self.notify("Diese Stelle lässt sich nicht genau zuordnen.", severity="warning")
|
||||
return
|
||||
|
||||
def noted(text: str | None) -> None:
|
||||
if text:
|
||||
self._ann.add(self._key, "highlights", annotations.new_note(anchor, text))
|
||||
self._sel = None
|
||||
self._annotations_changed()
|
||||
|
||||
self.app.push_screen(NoteScreen("Notiz"), noted)
|
||||
|
|
|
|||
|
|
@ -1,27 +1,24 @@
|
|||
"""Fetch + decrypt books from a diora server into the local TUI library, and
|
||||
pull reading progress for them into the local progress store.
|
||||
"""Talk to the diora server: pull the book list and reading state, push queued positions,
|
||||
download a book when it is opened.
|
||||
|
||||
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.
|
||||
Reading state follows docs/progress-sync.md (progress.py holds the rules). Books are listed
|
||||
from the server's list and fetched only when opened (the file is encrypted: decrypted here
|
||||
with the account's key and stored as a plain .epub in the library folder).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from . import api, crypto, library_meta
|
||||
from . import annotations, api, crypto, library, library_meta, progress
|
||||
from .config import RemoteConfig
|
||||
from .epub import _book_id
|
||||
from .progress import Position, save as save_progress
|
||||
from .epub import server_key
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
|
|
@ -30,11 +27,11 @@ class SyncError(Exception):
|
|||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
downloaded: list[str] = field(default_factory=list)
|
||||
unchanged: int = 0
|
||||
books: int = 0
|
||||
skipped_pdf: list[str] = field(default_factory=list)
|
||||
failed: list[str] = field(default_factory=list)
|
||||
progress_pulled: int = 0
|
||||
pushed: int = 0
|
||||
new_titles: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
|
|
@ -42,69 +39,155 @@ def _sanitize_filename(name: str) -> str:
|
|||
return (name or "buch")[:80]
|
||||
|
||||
|
||||
def _parse_meta(raw: bytes) -> dict:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
def local_path(library_dir: Path, server_id: int) -> Path | None:
|
||||
return next(library_dir.glob(f"{server_id:04d} - *.epub"), None)
|
||||
|
||||
|
||||
def flush_pending(cfg: RemoteConfig) -> int:
|
||||
"""Send every queued position. Stops quietly at the first network trouble (the rest stays
|
||||
queued); a book the server no longer has drops its queue."""
|
||||
store = progress.default()
|
||||
pushed = 0
|
||||
for key in store.pending_keys():
|
||||
st = store.get(key)
|
||||
if not (key.startswith("s") and key[1:].isdigit() and st and st.pending):
|
||||
continue
|
||||
body = dict(st.pending)
|
||||
try:
|
||||
answer = api.post_progress(
|
||||
cfg.server_url, cfg.api_token, int(key[1:]), anchor=body["anchor"], fraction=body["fraction"],
|
||||
read_at=body["read_at"], device=progress.DEVICE_NAME, force=bool(body.get("force")),
|
||||
)
|
||||
except api.ApiError as e:
|
||||
if "nicht gefunden" in str(e):
|
||||
store.sent(key, body, None)
|
||||
continue
|
||||
raise SyncError(str(e)) from e
|
||||
except requests.RequestException:
|
||||
break
|
||||
store.sent(key, body, answer)
|
||||
pushed += 1
|
||||
return pushed
|
||||
|
||||
|
||||
def pull_annotations(cfg: RemoteConfig, server_id: int, key: str) -> bool:
|
||||
"""The server's highlights and bookmarks of the book, laid under our queued edits. False when
|
||||
offline or the key doesn't open them (what is local stays)."""
|
||||
store = annotations.default()
|
||||
try:
|
||||
for kind in annotations.KINDS:
|
||||
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, server_id, kind))
|
||||
store.take_remote(key, kind, theirs)
|
||||
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def push_annotations(cfg: RemoteConfig) -> int:
|
||||
"""Write every queued list: merged with the server's current one first (annotations.py).
|
||||
Stops quietly at network trouble; the rest stays queued."""
|
||||
store = annotations.default()
|
||||
written = 0
|
||||
for key in store.dirty_keys():
|
||||
if not (key.startswith("s") and key[1:].isdigit()):
|
||||
continue
|
||||
book_id = int(key[1:])
|
||||
for kind in annotations.KINDS:
|
||||
if not store.get(key).dirty[kind]:
|
||||
continue
|
||||
try:
|
||||
theirs = annotations.decode(cfg.enc_key_b64, api.fetch_annotations(cfg.server_url, cfg.api_token, book_id, kind))
|
||||
merged, snapshot = store.for_push(key, kind, theirs)
|
||||
blob = annotations.encode(cfg.enc_key_b64, merged)
|
||||
if len(blob["ct"]) * 3 // 4 > annotations.MAX_BYTES[kind]:
|
||||
raise SyncError("Zu viele Markierungen für dieses Buch (Grenze des Servers erreicht).")
|
||||
api.post_annotations(cfg.server_url, cfg.api_token, book_id, kind, blob)
|
||||
except api.ApiError as e:
|
||||
if "nicht gefunden" in str(e):
|
||||
continue # the book is gone
|
||||
raise SyncError(str(e)) from e
|
||||
except (crypto.DecryptError, ValueError) as e:
|
||||
raise SyncError("Die Markierungen auf dem Server lassen sich nicht entschlüsseln.") from e
|
||||
except requests.RequestException:
|
||||
return written
|
||||
store.pushed(key, kind, merged, snapshot)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
||||
"""Push what is queued, then pull the book list and every book's reading state."""
|
||||
result = SyncResult()
|
||||
try:
|
||||
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
|
||||
result.pushed = flush_pending(cfg)
|
||||
try:
|
||||
push_annotations(cfg)
|
||||
except SyncError:
|
||||
pass # the notes stay queued; the book list is what this call is for
|
||||
books, server_now, device_now = api.fetch_books(cfg.server_url, cfg.api_token)
|
||||
except api.ApiError as e:
|
||||
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"]
|
||||
store = progress.default()
|
||||
store.set_server_now(server_now, device_now)
|
||||
known = library_meta.remote_entries()
|
||||
entries: dict[str, dict] = {}
|
||||
for b in books:
|
||||
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}")
|
||||
meta = json.loads(crypto.decrypt(cfg.enc_key_b64, b["meta_iv"], b["meta_ct"]).decode("utf-8"))
|
||||
except (crypto.DecryptError, ValueError):
|
||||
result.failed.append(f"#{b['id']}")
|
||||
continue
|
||||
|
||||
title = meta.get("title") or f"Buch {b['id']}"
|
||||
if meta.get("type") == "pdf":
|
||||
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
|
||||
result.skipped_pdf.append(title)
|
||||
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)
|
||||
|
||||
key = server_key(b["id"])
|
||||
entries[key] = {"server_id": b["id"], "title": title, "author": meta.get("author") or "",
|
||||
"is_read": bool(b.get("is_read")), "uploaded_at": b.get("uploaded_at") or "",
|
||||
"folders": library.folders_of_meta(meta), "pages": int(meta.get("pages") or 0),
|
||||
"magazine": meta.get("magazine") if isinstance(meta.get("magazine"), bool) else None}
|
||||
if key not in known:
|
||||
result.new_titles.append(title)
|
||||
store.apply_server(key, b)
|
||||
library_meta.set_remote(entries)
|
||||
result.books = len(entries)
|
||||
_pull_folder_order(cfg)
|
||||
return result
|
||||
|
||||
|
||||
def _pull_folder_order(cfg: RemoteConfig) -> None:
|
||||
"""The folders' order: encrypted library prefs {folder_order: [...]}. Best effort."""
|
||||
try:
|
||||
prefs = api.fetch_prefs(cfg.server_url, cfg.api_token)
|
||||
if not prefs.get("prefs_ct"):
|
||||
return
|
||||
plain = json.loads(crypto.decrypt(cfg.enc_key_b64, prefs["prefs_iv"], prefs["prefs_ct"]).decode("utf-8"))
|
||||
order = plain.get("folder_order")
|
||||
if isinstance(order, list):
|
||||
library_meta.set_extra("folder_order", [f for f in order if isinstance(f, str)])
|
||||
except (api.ApiError, crypto.DecryptError, ValueError, requests.RequestException):
|
||||
pass
|
||||
|
||||
|
||||
def download_book(library_dir: Path, cfg: RemoteConfig, server_id: int, title: str) -> Path:
|
||||
existing = local_path(library_dir, server_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
try:
|
||||
data = api.fetch_book_data(cfg.server_url, cfg.api_token, server_id)
|
||||
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
|
||||
except api.ApiError as e:
|
||||
raise SyncError(str(e)) from e
|
||||
except crypto.DecryptError as e:
|
||||
raise SyncError("Das Buch lässt sich mit dem gespeicherten Schlüssel nicht entschlüsseln.") from e
|
||||
except requests.RequestException as e:
|
||||
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
|
||||
library_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = library_dir / f"{server_id:04d} - {_sanitize_filename(title)}.epub"
|
||||
tmp = dest.with_suffix(".part")
|
||||
tmp.write_bytes(raw)
|
||||
os.replace(tmp, dest)
|
||||
return dest
|
||||
|
|
|
|||
55
tui/diora_tui/secretstore.py
Normal file
55
tui/diora_tui/secretstore.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Protect the account's token and book key at rest.
|
||||
|
||||
Windows: DPAPI (CryptProtectData), bound to this Windows user on this machine, as
|
||||
tools/diora_client.py in diora-android does. Elsewhere there is no such store in the standard
|
||||
library: the blob is plain base64 in a 0600 file (the same exposure the web client has with
|
||||
its key in localStorage), marked as such so a later version can move it into a keyring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import sys
|
||||
|
||||
_ENTROPY = b"diora-tui"
|
||||
|
||||
|
||||
def _dpapi(fn_name: str, data: bytes) -> bytes:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
|
||||
class Blob(ctypes.Structure):
|
||||
_fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
|
||||
|
||||
def blob(b: bytes):
|
||||
buf = ctypes.create_string_buffer(b, len(b))
|
||||
return Blob(len(b), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))), buf
|
||||
|
||||
inp, _k1 = blob(data)
|
||||
ent, _k2 = blob(_ENTROPY)
|
||||
out = Blob()
|
||||
fn = getattr(ctypes.windll.crypt32, fn_name)
|
||||
if not fn(ctypes.byref(inp), None, ctypes.byref(ent), None, None, 1, ctypes.byref(out)): # 1: no UI
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
return ctypes.string_at(out.pbData, out.cbData)
|
||||
finally:
|
||||
ctypes.windll.kernel32.LocalFree(out.pbData)
|
||||
|
||||
|
||||
def protect(data: bytes) -> str:
|
||||
if sys.platform == "win32":
|
||||
return "dpapi:" + base64.b64encode(_dpapi("CryptProtectData", data)).decode()
|
||||
return "plain:" + base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def unprotect(blob: str) -> bytes:
|
||||
kind, _, b64 = blob.partition(":")
|
||||
raw = base64.b64decode(b64)
|
||||
if kind == "dpapi":
|
||||
if sys.platform != "win32":
|
||||
raise ValueError("Zugangsdaten sind mit Windows-DPAPI geschützt und nur dort lesbar")
|
||||
return _dpapi("CryptUnprotectData", raw)
|
||||
if kind == "plain":
|
||||
return raw
|
||||
raise ValueError("unbekanntes Format der gespeicherten Zugangsdaten")
|
||||
|
|
@ -8,6 +8,7 @@ dependencies = [
|
|||
"ebooklib>=0.18",
|
||||
"beautifulsoup4>=4.12",
|
||||
"lxml>=5.0",
|
||||
"html5lib>=1.1",
|
||||
"platformdirs>=4.0",
|
||||
"requests>=2.31",
|
||||
"cryptography>=42.0",
|
||||
|
|
|
|||
55
tui/tests/conftest.py
Normal file
55
tui/tests/conftest.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Tests run against a throwaway home: progress, config and caches are module-level
|
||||
paths from platformdirs, read at import time, so the environment is set before anything
|
||||
from diora_tui is imported."""
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
_HOME = tempfile.mkdtemp(prefix="diora-tui-test-")
|
||||
for var in ("LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"):
|
||||
os.environ[var] = _HOME
|
||||
os.environ["HOME"] = _HOME
|
||||
os.environ["DIORA_TUI_HOME"] = _HOME # platformdirs ignores the variables above on Windows
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
CONTAINER = """<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>"""
|
||||
|
||||
|
||||
def make_epub(path: Path, chapters: list[str], title: str = "Testbuch") -> Path:
|
||||
"""A minimal EPUB whose spine is `chapters` (each one the full XHTML of a document)."""
|
||||
items = "".join(f'<item id="c{i}" href="c{i}.xhtml" media-type="application/xhtml+xml"/>' for i in range(len(chapters)))
|
||||
spine = "".join(f'<itemref idref="c{i}"/>' for i in range(len(chapters)))
|
||||
opf = f"""<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>{title}</dc:title><dc:creator>Autor</dc:creator>
|
||||
<dc:identifier id="id">test-{title}</dc:identifier><dc:language>de</dc:language></metadata>
|
||||
<manifest>{items}</manifest><spine>{spine}</spine></package>"""
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("mimetype", "application/epub+zip")
|
||||
z.writestr("META-INF/container.xml", CONTAINER)
|
||||
z.writestr("OEBPS/content.opf", opf)
|
||||
for i, body in enumerate(chapters):
|
||||
z.writestr(f"OEBPS/c{i}.xhtml", f'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>x</title></head><body>{body}</body></html>')
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def epub(tmp_path):
|
||||
def build(chapters, title="Testbuch"):
|
||||
return make_epub(tmp_path / f"{title}.epub", chapters, title)
|
||||
return build
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_library_meta():
|
||||
"""library.json is one file in the shared test home: every test starts without it."""
|
||||
from diora_tui import library_meta
|
||||
|
||||
library_meta._META_FILE.unlink(missing_ok=True)
|
||||
yield
|
||||
library_meta._META_FILE.unlink(missing_ok=True)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue