Compare commits
8 commits
master
...
worktree-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9683fbdbd | ||
|
|
20d04361bc | ||
|
|
b1a04d2a65 | ||
|
|
db3f520632 | ||
|
|
50f711831c | ||
|
|
a4b10ae265 | ||
|
|
3fcb74631c | ||
|
|
f63bd1f879 |
48 changed files with 1604 additions and 2765 deletions
|
|
@ -3,8 +3,3 @@ DEBUG=True
|
||||||
AMAZON_AFFILIATE_TAG=diora-20
|
AMAZON_AFFILIATE_TAG=diora-20
|
||||||
LASTFM_API_KEY=
|
LASTFM_API_KEY=
|
||||||
LASTFM_API_SECRET=
|
LASTFM_API_SECRET=
|
||||||
|
|
||||||
# Cloud import (WebDAV/Nextcloud): allow connections to LAN/loopback addresses.
|
|
||||||
# Leave False on any instance with open registration — it is what stops a user
|
|
||||||
# from probing the internal network through the import proxy.
|
|
||||||
WEBDAV_ALLOW_PRIVATE_HOSTS=False
|
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,16 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
# 22.04 ships Python 3.10, which cannot install the pinned Django (it
|
image: catthehacker/ubuntu:act-22.04
|
||||||
# needs >= 3.12). While requirements.txt still said "django>=4.2" that
|
|
||||||
# went unnoticed and pip quietly resolved a 4.x here — so CI was testing
|
|
||||||
# a different Django than the image actually shipped. 24.04 gives us
|
|
||||||
# 3.12, matching the Dockerfile's python:3.12-slim.
|
|
||||||
image: catthehacker/ubuntu:act-24.04
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
# 24.04 marks its system Python as externally managed (PEP 668), so a
|
run: pip install --no-cache-dir -r requirements.txt
|
||||||
# bare `pip install` is refused. A venv is the sanctioned way in.
|
|
||||||
run: |
|
|
||||||
python -m venv /tmp/venv
|
|
||||||
/tmp/venv/bin/pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
- name: Django system check
|
- name: Django system check
|
||||||
run: /tmp/venv/bin/python manage.py check
|
run: python manage.py check
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: /tmp/venv/bin/python manage.py test
|
run: python manage.py test
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,7 +19,6 @@ env/
|
||||||
media/
|
media/
|
||||||
staticfiles/
|
staticfiles/
|
||||||
.env
|
.env
|
||||||
tts_models/
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
18
Dockerfile
18
Dockerfile
|
|
@ -4,26 +4,10 @@ WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
# Piper voices for the reader's read-aloud feature (see tts/piper_engine.py).
|
|
||||||
# Fetched at build time rather than kept in git or bind-mounted — there's no
|
|
||||||
# existing volume-mount pattern for extra binary assets in this repo, and
|
|
||||||
# baking them into the image keeps the watchtower "just pull the new image"
|
|
||||||
# deploy flow working unchanged.
|
|
||||||
RUN mkdir -p /app/tts_models && \
|
|
||||||
curl -fsSL -o /app/tts_models/de_DE-thorsten-medium.onnx \
|
|
||||||
https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx && \
|
|
||||||
curl -fsSL -o /app/tts_models/de_DE-thorsten-medium.onnx.json \
|
|
||||||
https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json && \
|
|
||||||
curl -fsSL -o /app/tts_models/en_US-lessac-medium.onnx \
|
|
||||||
https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx && \
|
|
||||||
curl -fsSL -o /app/tts_models/en_US-lessac-medium.onnx.json \
|
|
||||||
https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json
|
|
||||||
|
|
||||||
ARG BUILD_TIME
|
ARG BUILD_TIME
|
||||||
ENV BUILD_TIME=${BUILD_TIME}
|
ENV BUILD_TIME=${BUILD_TIME}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# Generated by Django 6.1 on 2026-08-28 08:01
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('accounts', '0004_apitoken'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='WebDAVSource',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('label', models.CharField(max_length=100)),
|
|
||||||
('base_url', models.URLField(max_length=500)),
|
|
||||||
('username', models.CharField(blank=True, max_length=200)),
|
|
||||||
('password', models.CharField(blank=True, max_length=500)),
|
|
||||||
('root_path', models.CharField(blank=True, default='', max_length=500)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webdav_sources', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import secrets
|
import secrets
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
@ -38,57 +37,6 @@ def save_user_profile(sender, instance, **kwargs):
|
||||||
instance.profile.save()
|
instance.profile.save()
|
||||||
|
|
||||||
|
|
||||||
class WebDAVSource(models.Model):
|
|
||||||
"""A user-supplied WebDAV endpoint that ebooks can be imported from.
|
|
||||||
|
|
||||||
Deliberately generic rather than Nextcloud-specific — any WebDAV server
|
|
||||||
(Nextcloud, ownCloud, Synology, rclone serve, …) works, Nextcloud just gets
|
|
||||||
a URL-shorthand in `normalized_base_url`.
|
|
||||||
|
|
||||||
The password is stored in the clear because the server has to replay it on
|
|
||||||
every PROPFIND/GET (same trade-off as `lastfm_session_key` above). The UI
|
|
||||||
therefore tells users to create a revocable *app password* rather than
|
|
||||||
handing over their account password.
|
|
||||||
"""
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='webdav_sources')
|
|
||||||
label = models.CharField(max_length=100)
|
|
||||||
base_url = models.URLField(max_length=500)
|
|
||||||
username = models.CharField(max_length=200, blank=True)
|
|
||||||
password = models.CharField(max_length=500, blank=True)
|
|
||||||
root_path = models.CharField(max_length=500, blank=True, default='')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"WebDAVSource({self.label}, user={self.user_id})"
|
|
||||||
|
|
||||||
def normalized_base_url(self) -> str:
|
|
||||||
"""Collection root to resolve browse paths against, always ending in '/'.
|
|
||||||
|
|
||||||
A bare host ('https://cloud.example.com') is expanded to the Nextcloud
|
|
||||||
files endpoint, since that is the URL users actually have at hand; an
|
|
||||||
URL that already points into a DAV tree is left alone so non-Nextcloud
|
|
||||||
servers stay usable.
|
|
||||||
"""
|
|
||||||
url = self.base_url.strip()
|
|
||||||
if not url.endswith('/'):
|
|
||||||
url += '/'
|
|
||||||
# Match against the path only — a host literally named "webdav.…" or
|
|
||||||
# "dav.…" must not be mistaken for a URL that already points into a
|
|
||||||
# DAV tree.
|
|
||||||
path = urlsplit(url).path.lower()
|
|
||||||
is_dav = any(marker in path for marker in ('/remote.php/', '/dav/', '/webdav'))
|
|
||||||
if not is_dav and self.username:
|
|
||||||
url += f'remote.php/dav/files/{self.username}/'
|
|
||||||
root = self.root_path.strip().strip('/')
|
|
||||||
if root:
|
|
||||||
url += root + '/'
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_token():
|
def _generate_token():
|
||||||
return secrets.token_hex(32)
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import json
|
import json
|
||||||
import socket
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase
|
||||||
|
|
||||||
from .models import ApiToken, WebDAVSource
|
from .models import ApiToken
|
||||||
from .webdav import WebDAVError, assert_safe_url, fetch_file, list_directory, safe_rel_path
|
|
||||||
from books.models import EBook, EBookProgress, EBookHighlights
|
from books.models import EBook, EBookProgress, EBookHighlights
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -93,436 +89,3 @@ class SyncSnapshotTests(TestCase):
|
||||||
|
|
||||||
# No data_ct/data_iv leaked into the snapshot (book bytes stay lazy-fetched)
|
# No data_ct/data_iv leaked into the snapshot (book bytes stay lazy-fetched)
|
||||||
self.assertNotIn('data_ct', data['books'][0])
|
self.assertNotIn('data_ct', data['books'][0])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# WebDAV cloud import
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
PROPFIND_RESPONSE = b'''<?xml version="1.0"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response>
|
|
||||||
<d:href>/remote.php/dav/files/alice/Books/</d:href>
|
|
||||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
|
||||||
</d:response>
|
|
||||||
<d:response>
|
|
||||||
<d:href>/remote.php/dav/files/alice/Books/Sci-Fi/</d:href>
|
|
||||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat>
|
|
||||||
</d:response>
|
|
||||||
<d:response>
|
|
||||||
<d:href>/remote.php/dav/files/alice/Books/Der%20Steppenwolf.epub</d:href>
|
|
||||||
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>4096</d:getcontentlength></d:prop></d:propstat>
|
|
||||||
</d:response>
|
|
||||||
<d:response>
|
|
||||||
<d:href>/remote.php/dav/files/alice/Books/notes.txt</d:href>
|
|
||||||
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>12</d:getcontentlength></d:prop></d:propstat>
|
|
||||||
</d:response>
|
|
||||||
</d:multistatus>'''
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeSocket:
|
|
||||||
def __init__(self, peer):
|
|
||||||
self._peer = peer
|
|
||||||
|
|
||||||
def getpeername(self):
|
|
||||||
return (self._peer, 443)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
|
||||||
def __init__(self, status_code=207, content=b'', headers=None, peer=None):
|
|
||||||
self.status_code = status_code
|
|
||||||
self.content = content
|
|
||||||
self.headers = headers or {}
|
|
||||||
self.closed = False
|
|
||||||
# Mirrors requests' response.raw._connection.sock, which is what
|
|
||||||
# _assert_peer_is_safe introspects. None means "nothing to check".
|
|
||||||
if peer is None:
|
|
||||||
self.raw = None
|
|
||||||
else:
|
|
||||||
self.raw = SimpleNamespace(_connection=SimpleNamespace(sock=_FakeSocket(peer)))
|
|
||||||
|
|
||||||
def iter_content(self, chunk_size=None):
|
|
||||||
yield self.content
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self.closed = True
|
|
||||||
|
|
||||||
|
|
||||||
class NormalizedBaseUrlTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
|
|
||||||
def _source(self, **kwargs):
|
|
||||||
kwargs.setdefault('label', 'cloud')
|
|
||||||
kwargs.setdefault('username', 'alice')
|
|
||||||
return WebDAVSource(user=self.user, **kwargs)
|
|
||||||
|
|
||||||
def test_bare_host_expands_to_nextcloud_files_endpoint(self):
|
|
||||||
source = self._source(base_url='https://cloud.example.com')
|
|
||||||
self.assertEqual(
|
|
||||||
source.normalized_base_url(),
|
|
||||||
'https://cloud.example.com/remote.php/dav/files/alice/',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_root_path_is_appended(self):
|
|
||||||
source = self._source(base_url='https://cloud.example.com', root_path='/Buecher/')
|
|
||||||
self.assertEqual(
|
|
||||||
source.normalized_base_url(),
|
|
||||||
'https://cloud.example.com/remote.php/dav/files/alice/Buecher/',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_explicit_dav_url_is_left_alone(self):
|
|
||||||
source = self._source(base_url='https://dav.example.com/webdav')
|
|
||||||
self.assertEqual(source.normalized_base_url(), 'https://dav.example.com/webdav/')
|
|
||||||
|
|
||||||
def test_generic_server_without_username_is_not_rewritten(self):
|
|
||||||
source = self._source(base_url='https://files.example.com/share', username='')
|
|
||||||
self.assertEqual(source.normalized_base_url(), 'https://files.example.com/share/')
|
|
||||||
|
|
||||||
|
|
||||||
class SafeRelPathTests(TestCase):
|
|
||||||
def test_traversal_is_rejected(self):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
safe_rel_path('Books/../../etc/passwd')
|
|
||||||
|
|
||||||
def test_leading_slashes_and_dots_are_stripped(self):
|
|
||||||
self.assertEqual(safe_rel_path('/Books/./Sci-Fi/'), 'Books/Sci-Fi')
|
|
||||||
|
|
||||||
def test_empty_path_is_root(self):
|
|
||||||
self.assertEqual(safe_rel_path(''), '')
|
|
||||||
self.assertEqual(safe_rel_path('/'), '')
|
|
||||||
|
|
||||||
|
|
||||||
class AssertSafeUrlTests(TestCase):
|
|
||||||
def _resolve_to(self, ip):
|
|
||||||
return [(2, 1, 6, '', (ip, 443))]
|
|
||||||
|
|
||||||
def test_non_http_scheme_rejected(self):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
assert_safe_url('file:///etc/passwd')
|
|
||||||
|
|
||||||
def test_private_address_rejected(self):
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('172.18.0.4')):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
assert_safe_url('https://internal.example.com/dav/')
|
|
||||||
|
|
||||||
def test_loopback_rejected(self):
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('127.0.0.1')):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
assert_safe_url('http://localhost:11000/remote.php/dav/')
|
|
||||||
|
|
||||||
def test_link_local_metadata_endpoint_rejected(self):
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('169.254.169.254')):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
assert_safe_url('http://metadata.example.com/')
|
|
||||||
|
|
||||||
def test_public_address_allowed(self):
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo', return_value=self._resolve_to('85.214.6.118')):
|
|
||||||
assert_safe_url('https://nc.example.com/remote.php/dav/')
|
|
||||||
|
|
||||||
def test_unresolvable_host_rejected(self):
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo', side_effect=socket.gaierror):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
assert_safe_url('https://nope.example.com/')
|
|
||||||
|
|
||||||
@override_settings(WEBDAV_ALLOW_PRIVATE_HOSTS=True)
|
|
||||||
def test_private_allowed_when_opted_in(self):
|
|
||||||
assert_safe_url('http://192.168.1.10/dav/')
|
|
||||||
|
|
||||||
|
|
||||||
class ListDirectoryTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
self.source = WebDAVSource.objects.create(
|
|
||||||
user=self.user, label='cloud', base_url='https://cloud.example.com',
|
|
||||||
username='alice', password='app-pw', root_path='Books',
|
|
||||||
)
|
|
||||||
|
|
||||||
def _list(self, response=None):
|
|
||||||
with patch('accounts.webdav.assert_safe_url'), \
|
|
||||||
patch('accounts.webdav.requests.request',
|
|
||||||
return_value=response or _FakeResponse(content=PROPFIND_RESPONSE)):
|
|
||||||
return list_directory(self.source)
|
|
||||||
|
|
||||||
def test_entries_are_parsed_and_sorted_dirs_first(self):
|
|
||||||
entries = self._list()
|
|
||||||
self.assertEqual([e['name'] for e in entries],
|
|
||||||
['Sci-Fi', 'Der Steppenwolf.epub', 'notes.txt'])
|
|
||||||
|
|
||||||
def test_collection_itself_is_excluded(self):
|
|
||||||
self.assertNotIn('Books', [e['name'] for e in self._list()])
|
|
||||||
|
|
||||||
def test_book_flag_and_size(self):
|
|
||||||
by_name = {e['name']: e for e in self._list()}
|
|
||||||
self.assertTrue(by_name['Der Steppenwolf.epub']['is_book'])
|
|
||||||
self.assertEqual(by_name['Der Steppenwolf.epub']['size'], 4096)
|
|
||||||
self.assertFalse(by_name['notes.txt']['is_book'])
|
|
||||||
self.assertTrue(by_name['Sci-Fi']['is_dir'])
|
|
||||||
|
|
||||||
def test_redirect_is_refused_rather_than_followed(self):
|
|
||||||
redirect_response = _FakeResponse(status_code=302, headers={'Location': 'http://127.0.0.1/'})
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
self._list(redirect_response)
|
|
||||||
|
|
||||||
def test_bad_credentials_surface_clearly(self):
|
|
||||||
with self.assertRaises(WebDAVError) as ctx:
|
|
||||||
self._list(_FakeResponse(status_code=401))
|
|
||||||
self.assertIn('App-Passwort', str(ctx.exception))
|
|
||||||
|
|
||||||
|
|
||||||
class FetchFileTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
self.source = WebDAVSource.objects.create(
|
|
||||||
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_non_book_extension_refused_before_any_request(self):
|
|
||||||
with patch('accounts.webdav.requests.request') as mock_request:
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
fetch_file(self.source, 'secrets.env', 1024)
|
|
||||||
mock_request.assert_not_called()
|
|
||||||
|
|
||||||
def test_declared_oversize_refused(self):
|
|
||||||
response = _FakeResponse(status_code=200, headers={'Content-Length': '99999'})
|
|
||||||
with patch('accounts.webdav.assert_safe_url'), \
|
|
||||||
patch('accounts.webdav.requests.request', return_value=response):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
fetch_file(self.source, 'big.epub', 1024)
|
|
||||||
|
|
||||||
def test_streamed_oversize_refused_even_without_content_length(self):
|
|
||||||
response = _FakeResponse(status_code=200, content=b'x' * 5000)
|
|
||||||
with patch('accounts.webdav.assert_safe_url'), \
|
|
||||||
patch('accounts.webdav.requests.request', return_value=response):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
fetch_file(self.source, 'sneaky.epub', 1024)
|
|
||||||
|
|
||||||
def test_successful_fetch_returns_bytes(self):
|
|
||||||
response = _FakeResponse(status_code=200, content=b'EPUB-BYTES')
|
|
||||||
with patch('accounts.webdav.assert_safe_url'), \
|
|
||||||
patch('accounts.webdav.requests.request', return_value=response):
|
|
||||||
self.assertEqual(fetch_file(self.source, 'ok.epub', 1024), b'EPUB-BYTES')
|
|
||||||
|
|
||||||
|
|
||||||
class CloudImportViewTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
self.other = User.objects.create_user(username='bob', password='pw12345678')
|
|
||||||
self.source = WebDAVSource.objects.create(
|
|
||||||
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
|
|
||||||
)
|
|
||||||
self.foreign = WebDAVSource.objects.create(
|
|
||||||
user=self.other, label='bobs', base_url='https://other.example.com', username='bob',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_endpoints_require_authentication(self):
|
|
||||||
self.assertEqual(self.client.get('/books/cloud/sources/').status_code, 401)
|
|
||||||
self.assertEqual(self.client.get(f'/books/cloud/{self.source.pk}/browse/').status_code, 401)
|
|
||||||
self.assertEqual(self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=a.epub').status_code, 401)
|
|
||||||
|
|
||||||
def test_sources_are_scoped_to_the_owner(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
labels = [s['label'] for s in self.client.get('/books/cloud/sources/').json()['sources']]
|
|
||||||
self.assertEqual(labels, ['cloud'])
|
|
||||||
|
|
||||||
def test_foreign_source_is_not_browsable(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.get(f'/books/cloud/{self.foreign.pk}/browse/')
|
|
||||||
self.assertEqual(resp.status_code, 404)
|
|
||||||
|
|
||||||
def test_browse_returns_entries(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
entries = [{'name': 'Dune.epub', 'path': 'Dune.epub', 'is_dir': False,
|
|
||||||
'size': 10, 'modified': '', 'is_book': True}]
|
|
||||||
with patch('books.webdav.list_directory', return_value=entries):
|
|
||||||
resp = self.client.get(f'/books/cloud/{self.source.pk}/browse/')
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
self.assertEqual(resp.json()['entries'], entries)
|
|
||||||
|
|
||||||
def test_browse_reports_upstream_failure_as_502(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
with patch('books.webdav.list_directory', side_effect=WebDAVError('kaputt')):
|
|
||||||
resp = self.client.get(f'/books/cloud/{self.source.pk}/browse/')
|
|
||||||
self.assertEqual(resp.status_code, 502)
|
|
||||||
self.assertEqual(resp.json()['error'], 'kaputt')
|
|
||||||
|
|
||||||
def test_bad_user_input_is_400_not_502(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=../../etc/passwd')
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=notes.txt')
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
|
|
||||||
def test_fetch_streams_bytes_without_storing_them(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'):
|
|
||||||
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub')
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
self.assertEqual(resp.content, b'EPUB-BYTES')
|
|
||||||
# The proxy is a pass-through: nothing is persisted server-side.
|
|
||||||
self.assertEqual(EBook.objects.count(), 0)
|
|
||||||
|
|
||||||
|
|
||||||
BILLION_LAUGHS = b'''<?xml version="1.0"?>
|
|
||||||
<!DOCTYPE lolz [
|
|
||||||
<!ENTITY lol "lol">
|
|
||||||
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
|
|
||||||
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
|
|
||||||
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
|
|
||||||
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
|
|
||||||
]>
|
|
||||||
<d:multistatus xmlns:d="DAV:"><d:response><d:href>&lol4;</d:href></d:response></d:multistatus>'''
|
|
||||||
|
|
||||||
# A server that answers with the 404 propstat first — legal per RFC 4918, and
|
|
||||||
# what made directories disappear from the listing before _select_prop existed.
|
|
||||||
PROPSTAT_404_FIRST = b'''<?xml version="1.0"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response>
|
|
||||||
<d:href>/remote.php/dav/files/alice/Books/</d:href>
|
|
||||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
|
||||||
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
||||||
</d:response>
|
|
||||||
<d:response>
|
|
||||||
<d:href>/remote.php/dav/files/alice/Books/Sci-Fi/</d:href>
|
|
||||||
<d:propstat><d:prop><d:getcontentlength/></d:prop>
|
|
||||||
<d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>
|
|
||||||
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
|
||||||
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
||||||
</d:response>
|
|
||||||
</d:multistatus>'''
|
|
||||||
|
|
||||||
RELATIVE_HREF_RESPONSE = b'''<?xml version="1.0"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>Dune.epub</d:href>
|
|
||||||
<d:propstat><d:prop><d:resourcetype/><d:getcontentlength>7</d:getcontentlength></d:prop>
|
|
||||||
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
||||||
</d:response>
|
|
||||||
</d:multistatus>'''
|
|
||||||
|
|
||||||
|
|
||||||
class WebDAVHardeningTests(TestCase):
|
|
||||||
"""Regressions for the SSRF / resource-exhaustion review findings."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
self.source = WebDAVSource.objects.create(
|
|
||||||
user=self.user, label='cloud', base_url='https://cloud.example.com',
|
|
||||||
username='alice', password='app-pw', root_path='Books',
|
|
||||||
)
|
|
||||||
|
|
||||||
def _list(self, response):
|
|
||||||
with patch('accounts.webdav.assert_safe_url'), \
|
|
||||||
patch('accounts.webdav.requests.request', return_value=response):
|
|
||||||
return list_directory(self.source)
|
|
||||||
|
|
||||||
# --- DNS rebinding -----------------------------------------------------
|
|
||||||
|
|
||||||
def test_peer_address_is_rechecked_after_connecting(self):
|
|
||||||
"""A resolver that answers public-then-private must not leak a body.
|
|
||||||
|
|
||||||
assert_safe_url passes (it is given a public answer), but the socket
|
|
||||||
actually landed on a Docker-internal address.
|
|
||||||
"""
|
|
||||||
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='172.18.0.5')
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
self._list(response)
|
|
||||||
self.assertTrue(response.closed)
|
|
||||||
|
|
||||||
def test_public_peer_is_accepted(self):
|
|
||||||
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='85.214.6.118')
|
|
||||||
self.assertTrue(self._list(response))
|
|
||||||
|
|
||||||
@override_settings(WEBDAV_ALLOW_PRIVATE_HOSTS=True)
|
|
||||||
def test_peer_check_respects_the_opt_out(self):
|
|
||||||
response = _FakeResponse(content=PROPFIND_RESPONSE, peer='192.168.1.10')
|
|
||||||
self.assertTrue(self._list(response))
|
|
||||||
|
|
||||||
# --- Resource exhaustion ----------------------------------------------
|
|
||||||
|
|
||||||
def test_entity_expansion_is_refused(self):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
self._list(_FakeResponse(content=BILLION_LAUGHS))
|
|
||||||
|
|
||||||
def test_oversized_listing_is_refused(self):
|
|
||||||
oversized = _FakeResponse(headers={'Content-Length': str(9 * 1024 * 1024)})
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
self._list(oversized)
|
|
||||||
self.assertTrue(oversized.closed)
|
|
||||||
|
|
||||||
# --- Information disclosure -------------------------------------------
|
|
||||||
|
|
||||||
def test_rejection_message_leaks_neither_ip_nor_resolvability(self):
|
|
||||||
private = [(2, 1, 6, '', ('172.18.0.5', 443))]
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo', return_value=private):
|
|
||||||
with self.assertRaises(WebDAVError) as private_ctx:
|
|
||||||
assert_safe_url('http://forgejo-db/')
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo', side_effect=socket.gaierror):
|
|
||||||
with self.assertRaises(WebDAVError) as missing_ctx:
|
|
||||||
assert_safe_url('http://forgejo-db/')
|
|
||||||
|
|
||||||
self.assertNotIn('172.18.0.5', str(private_ctx.exception))
|
|
||||||
# Same wording either way, so the endpoint cannot be used to tell an
|
|
||||||
# existing internal host from a nonexistent one.
|
|
||||||
self.assertEqual(str(private_ctx.exception), str(missing_ctx.exception))
|
|
||||||
|
|
||||||
# --- Malformed input ---------------------------------------------------
|
|
||||||
|
|
||||||
def test_invalid_port_is_reported_not_crashed(self):
|
|
||||||
with self.assertRaises(WebDAVError):
|
|
||||||
assert_safe_url('https://example.com:99999/dav/')
|
|
||||||
|
|
||||||
def test_ipv6_transition_ranges_are_rejected(self):
|
|
||||||
for address in ('64:ff9b::7f00:1', '::127.0.0.1'):
|
|
||||||
with patch('accounts.webdav.socket.getaddrinfo',
|
|
||||||
return_value=[(10, 1, 6, '', (address, 443, 0, 0))]):
|
|
||||||
with self.assertRaises(WebDAVError, msg=address):
|
|
||||||
assert_safe_url('https://nat64.example.com/')
|
|
||||||
|
|
||||||
# --- PROPFIND parsing --------------------------------------------------
|
|
||||||
|
|
||||||
def test_directory_survives_a_404_propstat_listed_first(self):
|
|
||||||
entries = self._list(_FakeResponse(content=PROPSTAT_404_FIRST))
|
|
||||||
by_name = {e['name']: e for e in entries}
|
|
||||||
self.assertTrue(by_name['Sci-Fi']['is_dir'])
|
|
||||||
|
|
||||||
def test_relative_hrefs_are_resolved(self):
|
|
||||||
entries = self._list(_FakeResponse(content=RELATIVE_HREF_RESPONSE))
|
|
||||||
self.assertEqual([e['name'] for e in entries], ['Dune.epub'])
|
|
||||||
|
|
||||||
# --- URL normalisation -------------------------------------------------
|
|
||||||
|
|
||||||
def test_host_named_webdav_still_gets_the_nextcloud_path(self):
|
|
||||||
source = WebDAVSource(user=self.user, label='x',
|
|
||||||
base_url='https://webdav.example.com', username='alice')
|
|
||||||
self.assertEqual(
|
|
||||||
source.normalized_base_url(),
|
|
||||||
'https://webdav.example.com/remote.php/dav/files/alice/',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WebDAVSourceLimitTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
@override_settings(WEBDAV_MAX_SOURCES_PER_USER=2)
|
|
||||||
def test_sources_are_capped_per_user(self):
|
|
||||||
with patch('accounts.views._probe_source', return_value=(20, 'ok')):
|
|
||||||
for i in range(3):
|
|
||||||
self.client.post('/accounts/webdav/add/',
|
|
||||||
{'label': f'c{i}', 'base_url': 'https://example.com'})
|
|
||||||
self.assertEqual(self.user.webdav_sources.count(), 2)
|
|
||||||
|
|
||||||
|
|
||||||
class CloudFetchCachingTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
self.source = WebDAVSource.objects.create(
|
|
||||||
user=self.user, label='cloud', base_url='https://cloud.example.com', username='alice',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_plaintext_bytes_are_not_cacheable(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'):
|
|
||||||
resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub')
|
|
||||||
self.assertEqual(resp['Cache-Control'], 'no-store')
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,4 @@ urlpatterns = [
|
||||||
path('check-password/', views.check_password, name='check_password'),
|
path('check-password/', views.check_password, name='check_password'),
|
||||||
path('change-password/', views.change_password, name='change_password'),
|
path('change-password/', views.change_password, name='change_password'),
|
||||||
path('api-token/regenerate/', views.regenerate_api_token, name='regenerate_api_token'),
|
path('api-token/regenerate/', views.regenerate_api_token, name='regenerate_api_token'),
|
||||||
path('webdav/add/', views.webdav_add, name='webdav_add'),
|
|
||||||
path('webdav/<int:pk>/test/', views.webdav_test, name='webdav_test'),
|
|
||||||
path('webdav/<int:pk>/delete/', views.webdav_delete, name='webdav_delete'),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.auth import authenticate, login, get_user_model, update_session_auth_hash
|
from django.contrib.auth import authenticate, login, get_user_model, update_session_auth_hash
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm
|
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordChangeForm
|
||||||
|
|
@ -12,8 +11,7 @@ from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from radio import lastfm as lastfm_module
|
from radio import lastfm as lastfm_module
|
||||||
|
|
||||||
from .models import ApiToken, WebDAVSource
|
from .models import ApiToken
|
||||||
from .webdav import WebDAVError, list_directory
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
@ -77,7 +75,6 @@ def settings_view(request):
|
||||||
'has_lastfm': profile.has_lastfm(),
|
'has_lastfm': profile.has_lastfm(),
|
||||||
'password_form': PasswordChangeForm(request.user),
|
'password_form': PasswordChangeForm(request.user),
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'api_token': getattr(request.user, 'api_token', None),
|
||||||
'webdav_sources': request.user.webdav_sources.all(),
|
|
||||||
}
|
}
|
||||||
return render(request, 'accounts/settings.html', context)
|
return render(request, 'accounts/settings.html', context)
|
||||||
|
|
||||||
|
|
@ -216,7 +213,6 @@ def change_password(request):
|
||||||
'password_form': form,
|
'password_form': form,
|
||||||
'password_form_open': True,
|
'password_form_open': True,
|
||||||
'api_token': getattr(request.user, 'api_token', None),
|
'api_token': getattr(request.user, 'api_token', None),
|
||||||
'webdav_sources': request.user.webdav_sources.all(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -240,73 +236,3 @@ def regenerate_api_token(request):
|
||||||
token, _ = ApiToken.objects.get_or_create(user=request.user)
|
token, _ = ApiToken.objects.get_or_create(user=request.user)
|
||||||
token.regenerate()
|
token.regenerate()
|
||||||
return redirect('settings')
|
return redirect('settings')
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# WebDAV sources (Nextcloud & friends) — used by the ebook cloud import
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _probe_source(source):
|
|
||||||
"""Report a source's reachability as a (level, message) pair for messages."""
|
|
||||||
try:
|
|
||||||
entries = list_directory(source)
|
|
||||||
except WebDAVError as exc:
|
|
||||||
return messages.WARNING, f'„{source.label}“ gespeichert, aber nicht erreichbar: {exc}'
|
|
||||||
books = sum(1 for e in entries if e['is_book'])
|
|
||||||
folders = sum(1 for e in entries if e['is_dir'])
|
|
||||||
return messages.SUCCESS, (
|
|
||||||
f'„{source.label}“ verbunden — {books} Buch/Bücher und {folders} Ordner im Startverzeichnis.'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def webdav_add(request):
|
|
||||||
label = request.POST.get('label', '').strip()
|
|
||||||
base_url = request.POST.get('base_url', '').strip()
|
|
||||||
|
|
||||||
if not label or not base_url:
|
|
||||||
messages.error(request, 'Name und Server-URL sind erforderlich.')
|
|
||||||
return redirect('settings')
|
|
||||||
|
|
||||||
# Each source costs a synchronous probe on add and is reachable from the
|
|
||||||
# import endpoints, so cap how many one account can pile up.
|
|
||||||
max_sources = getattr(settings, 'WEBDAV_MAX_SOURCES_PER_USER', 10)
|
|
||||||
if request.user.webdav_sources.count() >= max_sources:
|
|
||||||
messages.error(request, f'Maximal {max_sources} Cloud-Verbindungen pro Konto.')
|
|
||||||
return redirect('settings')
|
|
||||||
|
|
||||||
source = WebDAVSource.objects.create(
|
|
||||||
user=request.user,
|
|
||||||
label=label[:100],
|
|
||||||
base_url=base_url[:500],
|
|
||||||
username=request.POST.get('username', '').strip()[:200],
|
|
||||||
password=request.POST.get('password', '')[:500],
|
|
||||||
root_path=request.POST.get('root_path', '').strip()[:500],
|
|
||||||
)
|
|
||||||
|
|
||||||
level, message = _probe_source(source)
|
|
||||||
messages.add_message(request, level, message)
|
|
||||||
return redirect('settings')
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def webdav_test(request, pk):
|
|
||||||
source = WebDAVSource.objects.filter(pk=pk, user=request.user).first()
|
|
||||||
if not source:
|
|
||||||
messages.error(request, 'Verbindung nicht gefunden.')
|
|
||||||
return redirect('settings')
|
|
||||||
|
|
||||||
level, message = _probe_source(source)
|
|
||||||
messages.add_message(request, level, message)
|
|
||||||
return redirect('settings')
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def webdav_delete(request, pk):
|
|
||||||
deleted, _ = WebDAVSource.objects.filter(pk=pk, user=request.user).delete()
|
|
||||||
if deleted:
|
|
||||||
messages.success(request, 'Verbindung entfernt.')
|
|
||||||
return redirect('settings')
|
|
||||||
|
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
"""Minimal, hardened WebDAV client behind the ebook cloud import.
|
|
||||||
|
|
||||||
Only PROPFIND (Depth 1) and GET are implemented — enough to browse a remote
|
|
||||||
folder and pull a single file out of it.
|
|
||||||
|
|
||||||
Every request here is made by the server to a URL the *user* supplied, which
|
|
||||||
makes this an SSRF surface: registration is open (accounts/views.py:23) and
|
|
||||||
diora normally runs in a Docker network next to other services. Three things
|
|
||||||
guard it, and all three are load-bearing:
|
|
||||||
|
|
||||||
* `assert_safe_url` refuses hosts that resolve to non-public addresses,
|
|
||||||
* `_assert_peer_is_safe` re-checks the address we *actually* connected to,
|
|
||||||
because requests resolves the hostname a second time and a hostile resolver
|
|
||||||
can answer differently across the two lookups (DNS rebinding),
|
|
||||||
* redirects are refused rather than followed, so a public host cannot bounce
|
|
||||||
us onto a private one.
|
|
||||||
|
|
||||||
Residual risk worth knowing about: a rebinding attacker can still cause a
|
|
||||||
single request to be *sent* to an internal address; the peer check runs before
|
|
||||||
the body is read, so nothing comes back to them. Fully closing that needs
|
|
||||||
connect-time DNS pinning, which requests does not expose without reaching into
|
|
||||||
urllib3.
|
|
||||||
|
|
||||||
Response bodies are read through `_read_capped` and parsed only after any DTD
|
|
||||||
is refused — the remote server is attacker-chosen, so an unbounded read or a
|
|
||||||
billion-laughs document would otherwise be a cheap way to OOM the container.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import socket
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from urllib.parse import quote, unquote, urljoin, urlsplit
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
DAV_NS = '{DAV:}'
|
|
||||||
|
|
||||||
BOOK_EXTENSIONS = ('.epub', '.pdf')
|
|
||||||
|
|
||||||
# A directory listing is XML; anything this large is not a real one.
|
|
||||||
MAX_LISTING_BYTES = 8 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class WebDAVError(Exception):
|
|
||||||
"""Failure with a message that is safe to show the user directly."""
|
|
||||||
|
|
||||||
|
|
||||||
class WebDAVInputError(WebDAVError):
|
|
||||||
"""The user's own URL/path/credentials are at fault, not the remote server.
|
|
||||||
|
|
||||||
Separated so views can answer 400 instead of 502 — a rejected traversal
|
|
||||||
attempt is not an upstream outage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _timeout():
|
|
||||||
return getattr(settings, 'WEBDAV_TIMEOUT', 15)
|
|
||||||
|
|
||||||
|
|
||||||
def _allow_private():
|
|
||||||
return getattr(settings, 'WEBDAV_ALLOW_PRIVATE_HOSTS', False)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SSRF guard
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _unreachable(host):
|
|
||||||
"""One message for every "we won't talk to this" case.
|
|
||||||
|
|
||||||
Deliberately does not say whether the name failed to resolve or resolved to
|
|
||||||
a private address, and never echoes the resolved IP: telling those apart
|
|
||||||
would turn this endpoint into a scanner for the Docker network's service
|
|
||||||
names and addresses.
|
|
||||||
"""
|
|
||||||
return WebDAVInputError(
|
|
||||||
f'„{host}“ ist nicht öffentlich erreichbar. Aus Sicherheitsgründen sind nur '
|
|
||||||
'öffentlich auflösbare Server erlaubt.'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_public(raw_address):
|
|
||||||
"""True only for addresses we are willing to open a connection to."""
|
|
||||||
try:
|
|
||||||
# Scope IDs ('fe80::1%eth0') are not part of the address itself.
|
|
||||||
ip = ipaddress.ip_address(raw_address.split('%')[0])
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
if not ip.is_global:
|
|
||||||
return False
|
|
||||||
# is_global misses two IPv6 transition ranges that can carry an embedded
|
|
||||||
# private IPv4 address: the well-known NAT64 prefix (RFC 6052) and the
|
|
||||||
# deprecated IPv4-compatible range.
|
|
||||||
if ip.version == 6:
|
|
||||||
for unsafe in ('64:ff9b::/96', '::/96'):
|
|
||||||
if ip in ipaddress.ip_network(unsafe):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def assert_safe_url(url):
|
|
||||||
"""Reject URLs that don't resolve to a public address.
|
|
||||||
|
|
||||||
Self-hosted setups that legitimately want a LAN target can opt out with
|
|
||||||
WEBDAV_ALLOW_PRIVATE_HOSTS=True, which is why this is a setting and not a
|
|
||||||
hard rule.
|
|
||||||
"""
|
|
||||||
parts = urlsplit(url)
|
|
||||||
if parts.scheme not in ('http', 'https'):
|
|
||||||
raise WebDAVInputError('Nur http:// und https:// werden unterstützt.')
|
|
||||||
|
|
||||||
host = parts.hostname
|
|
||||||
if not host:
|
|
||||||
raise WebDAVInputError('Die URL enthält keinen Hostnamen.')
|
|
||||||
|
|
||||||
try:
|
|
||||||
port = parts.port or (443 if parts.scheme == 'https' else 80)
|
|
||||||
except ValueError:
|
|
||||||
# urlsplit validates the port lazily, on attribute access.
|
|
||||||
raise WebDAVInputError('Die URL enthält einen ungültigen Port.')
|
|
||||||
|
|
||||||
if _allow_private():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
||||||
except socket.gaierror:
|
|
||||||
raise _unreachable(host)
|
|
||||||
|
|
||||||
for info in infos:
|
|
||||||
if not _is_public(info[4][0]):
|
|
||||||
raise _unreachable(host)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_peer_is_safe(response):
|
|
||||||
"""Re-check the address the socket actually connected to.
|
|
||||||
|
|
||||||
`assert_safe_url` validates DNS before the request, but requests resolves
|
|
||||||
the name again when it opens the connection. A resolver answering
|
|
||||||
public-then-private across those two lookups would otherwise hand us an
|
|
||||||
internal service's response body.
|
|
||||||
"""
|
|
||||||
if _allow_private():
|
|
||||||
return
|
|
||||||
sock = getattr(getattr(response.raw, '_connection', None), 'sock', None)
|
|
||||||
if sock is None:
|
|
||||||
return # nothing to introspect (mocked, or already released)
|
|
||||||
try:
|
|
||||||
peer = sock.getpeername()[0]
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
if not _is_public(peer):
|
|
||||||
raise WebDAVInputError('Der Server ist nicht öffentlich erreichbar.')
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Path helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def safe_rel_path(raw):
|
|
||||||
"""Normalise a client-supplied path and keep it inside the source root.
|
|
||||||
|
|
||||||
Whatever survives here is percent-encoded by `_build_url` with `quote()`'s
|
|
||||||
default `safe='/'`, and that is what stops double-encoded traversal
|
|
||||||
('%252e%252e%252f') and smuggled absolute URLs: '%' and ':' both get
|
|
||||||
escaped, so they land as literal filename characters. Widening that safe
|
|
||||||
set would reopen those paths.
|
|
||||||
"""
|
|
||||||
raw = (raw or '').strip().strip('/')
|
|
||||||
if not raw:
|
|
||||||
return ''
|
|
||||||
segments = []
|
|
||||||
for segment in raw.split('/'):
|
|
||||||
if not segment or segment == '.':
|
|
||||||
continue
|
|
||||||
if segment == '..':
|
|
||||||
raise WebDAVInputError('Ungültiger Pfad.')
|
|
||||||
segments.append(segment)
|
|
||||||
return '/'.join(segments)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_url(source, rel_path):
|
|
||||||
base = source.normalized_base_url()
|
|
||||||
if not rel_path:
|
|
||||||
return base
|
|
||||||
return base + quote(rel_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _request(method, url, source, **kwargs):
|
|
||||||
assert_safe_url(url)
|
|
||||||
auth = (source.username, source.password) if source.username else None
|
|
||||||
try:
|
|
||||||
# Always streamed: it keeps the connection open long enough to inspect
|
|
||||||
# the peer, and stops requests from buffering an unbounded body before
|
|
||||||
# we get the chance to cap it.
|
|
||||||
response = requests.request(
|
|
||||||
method, url, auth=auth, timeout=_timeout(),
|
|
||||||
allow_redirects=False, stream=True, **kwargs
|
|
||||||
)
|
|
||||||
except requests.Timeout:
|
|
||||||
raise WebDAVError('Zeitüberschreitung beim Server.')
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
raise WebDAVError(f'Verbindung fehlgeschlagen: {exc.__class__.__name__}')
|
|
||||||
|
|
||||||
try:
|
|
||||||
_assert_peer_is_safe(response)
|
|
||||||
|
|
||||||
if response.status_code in (301, 302, 303, 307, 308):
|
|
||||||
raise WebDAVError(
|
|
||||||
'Der Server hat eine Weiterleitung geschickt. '
|
|
||||||
'Bitte trage die Ziel-URL direkt ein.'
|
|
||||||
)
|
|
||||||
if response.status_code in (401, 403):
|
|
||||||
raise WebDAVInputError('Anmeldung abgelehnt — Benutzername oder App-Passwort prüfen.')
|
|
||||||
if response.status_code == 404:
|
|
||||||
raise WebDAVError('Pfad auf dem Server nicht gefunden.')
|
|
||||||
if response.status_code >= 400:
|
|
||||||
raise WebDAVError(f'Server antwortete mit HTTP {response.status_code}.')
|
|
||||||
except Exception:
|
|
||||||
response.close()
|
|
||||||
raise
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _read_capped(response, max_bytes, oversize_error):
|
|
||||||
"""Read a streamed body, aborting as soon as it exceeds `max_bytes`."""
|
|
||||||
declared = response.headers.get('Content-Length')
|
|
||||||
if declared and declared.isdigit() and int(declared) > max_bytes:
|
|
||||||
response.close()
|
|
||||||
raise oversize_error()
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
total = 0
|
|
||||||
try:
|
|
||||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
|
||||||
total += len(chunk)
|
|
||||||
if total > max_bytes:
|
|
||||||
raise oversize_error()
|
|
||||||
chunks.append(chunk)
|
|
||||||
except requests.RequestException:
|
|
||||||
raise WebDAVError('Übertragung abgebrochen.')
|
|
||||||
finally:
|
|
||||||
response.close()
|
|
||||||
|
|
||||||
return b''.join(chunks)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# PROPFIND / GET
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_PROPFIND_BODY = (
|
|
||||||
'<?xml version="1.0" encoding="utf-8"?>'
|
|
||||||
'<d:propfind xmlns:d="DAV:"><d:prop>'
|
|
||||||
'<d:resourcetype/><d:getcontentlength/><d:getlastmodified/>'
|
|
||||||
'</d:prop></d:propfind>'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_listing_xml(payload):
|
|
||||||
"""Parse a multistatus body, refusing any document that carries a DTD.
|
|
||||||
|
|
||||||
The remote server is chosen by the user, so a billion-laughs document is
|
|
||||||
within an attacker's reach, and ElementTree *does* expand internal entities
|
|
||||||
(verified on 3.12: a 3-level document expands). Installing an expat
|
|
||||||
EntityDeclHandler is not an option either — `XMLParser.parser` no longer
|
|
||||||
exists in 3.12, so setting it silently does nothing.
|
|
||||||
|
|
||||||
That leaves rejecting the DTD before parsing. It costs nothing: a real
|
|
||||||
multistatus never has one, and a filename containing this text would arrive
|
|
||||||
escaped as `<!DOCTYPE`, so these bytes can only ever be markup.
|
|
||||||
"""
|
|
||||||
lowered = payload.lower()
|
|
||||||
if b'<!doctype' in lowered or b'<!entity' in lowered:
|
|
||||||
raise WebDAVError('Die Antwort enthält eine DTD und wurde abgelehnt.')
|
|
||||||
|
|
||||||
try:
|
|
||||||
return ET.fromstring(payload)
|
|
||||||
except (ET.ParseError, ValueError):
|
|
||||||
raise WebDAVError('Unerwartete Antwort — ist das wirklich eine WebDAV-URL?')
|
|
||||||
|
|
||||||
|
|
||||||
def _select_prop(node):
|
|
||||||
"""Return the <prop> block that actually applies.
|
|
||||||
|
|
||||||
RFC 4918 lets a server split its answer across several <propstat> blocks —
|
|
||||||
typically a 200 for the properties it found and a 404 for the ones it did
|
|
||||||
not, in no guaranteed order. Taking the first one blindly mislabels
|
|
||||||
directories (whose getcontentlength is the missing property) as files, and
|
|
||||||
the client then filters them out of the listing entirely.
|
|
||||||
"""
|
|
||||||
fallback = None
|
|
||||||
for propstat in node.findall(f'{DAV_NS}propstat'):
|
|
||||||
prop = propstat.find(f'{DAV_NS}prop')
|
|
||||||
if prop is None:
|
|
||||||
continue
|
|
||||||
if ' 200 ' in (propstat.findtext(f'{DAV_NS}status') or ''):
|
|
||||||
return prop
|
|
||||||
if fallback is None:
|
|
||||||
fallback = prop
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def list_directory(source, rel_path=''):
|
|
||||||
"""PROPFIND Depth 1, returned as entries relative to the source root."""
|
|
||||||
rel_path = safe_rel_path(rel_path)
|
|
||||||
url = _build_url(source, rel_path)
|
|
||||||
if not url.endswith('/'):
|
|
||||||
url += '/'
|
|
||||||
|
|
||||||
response = _request(
|
|
||||||
'PROPFIND', url, source,
|
|
||||||
headers={'Depth': '1', 'Content-Type': 'application/xml; charset=utf-8'},
|
|
||||||
data=_PROPFIND_BODY.encode('utf-8'),
|
|
||||||
)
|
|
||||||
payload = _read_capped(
|
|
||||||
response, MAX_LISTING_BYTES,
|
|
||||||
lambda: WebDAVError('Verzeichnisliste ist unerwartet groß.'),
|
|
||||||
)
|
|
||||||
|
|
||||||
root = _parse_listing_xml(payload)
|
|
||||||
base_path = unquote(urlsplit(url).path)
|
|
||||||
entries = []
|
|
||||||
|
|
||||||
for node in root.findall(f'{DAV_NS}response'):
|
|
||||||
href = node.findtext(f'{DAV_NS}href') or ''
|
|
||||||
# hrefs come as absolute URLs, absolute paths, or relative references.
|
|
||||||
href_path = unquote(urlsplit(urljoin(url, href)).path)
|
|
||||||
if not href_path.startswith(base_path):
|
|
||||||
continue
|
|
||||||
remainder = href_path[len(base_path):].strip('/')
|
|
||||||
if not remainder:
|
|
||||||
continue # the collection we asked about
|
|
||||||
if '/' in remainder:
|
|
||||||
continue # Depth 1 should not return these, but be strict anyway
|
|
||||||
|
|
||||||
prop = _select_prop(node)
|
|
||||||
is_dir = (
|
|
||||||
prop is not None
|
|
||||||
and prop.find(f'{DAV_NS}resourcetype/{DAV_NS}collection') is not None
|
|
||||||
)
|
|
||||||
size_text = prop.findtext(f'{DAV_NS}getcontentlength') if prop is not None else None
|
|
||||||
try:
|
|
||||||
size = int(size_text) if size_text else 0
|
|
||||||
except ValueError:
|
|
||||||
size = 0
|
|
||||||
|
|
||||||
entries.append({
|
|
||||||
'name': remainder,
|
|
||||||
'path': f'{rel_path}/{remainder}' if rel_path else remainder,
|
|
||||||
'is_dir': is_dir,
|
|
||||||
'size': size,
|
|
||||||
'modified': (prop.findtext(f'{DAV_NS}getlastmodified') or '') if prop is not None else '',
|
|
||||||
'is_book': (not is_dir) and remainder.lower().endswith(BOOK_EXTENSIONS),
|
|
||||||
})
|
|
||||||
|
|
||||||
entries.sort(key=lambda e: (not e['is_dir'], e['name'].lower()))
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_file(source, rel_path, max_bytes):
|
|
||||||
"""Download one file, refusing anything larger than `max_bytes`.
|
|
||||||
|
|
||||||
Reads into memory rather than to disk: the caller hands the bytes straight
|
|
||||||
back to the browser, which encrypts them. Nothing readable is persisted
|
|
||||||
server-side.
|
|
||||||
"""
|
|
||||||
rel_path = safe_rel_path(rel_path)
|
|
||||||
if not rel_path:
|
|
||||||
raise WebDAVInputError('Kein Pfad angegeben.')
|
|
||||||
if not rel_path.lower().endswith(BOOK_EXTENSIONS):
|
|
||||||
raise WebDAVInputError('Nur .epub- und .pdf-Dateien können importiert werden.')
|
|
||||||
|
|
||||||
response = _request('GET', _build_url(source, rel_path), source)
|
|
||||||
return _read_capped(
|
|
||||||
response, max_bytes,
|
|
||||||
lambda: WebDAVInputError(f'Datei ist zu groß (max. {max_bytes // 1024 // 1024} MB).'),
|
|
||||||
)
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views, webdav
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.book_list, name='book_list'),
|
path('', views.book_list, name='book_list'),
|
||||||
path('upload/', views.upload_book, name='upload_book'),
|
path('upload/', views.upload_book, name='upload_book'),
|
||||||
path('cloud/sources/', webdav.cloud_sources, name='cloud_sources'),
|
|
||||||
path('cloud/<int:pk>/browse/', webdav.cloud_browse, name='cloud_browse'),
|
|
||||||
path('cloud/<int:pk>/fetch/', webdav.cloud_fetch, name='cloud_fetch'),
|
|
||||||
path('metadata-lookup/', views.lookup_book_metadata, name='lookup_book_metadata'),
|
|
||||||
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
||||||
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
||||||
path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
|
path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
|
||||||
path('<int:pk>/meta/', views.update_book_meta, name='update_book_meta'),
|
|
||||||
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
|
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
|
||||||
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
|
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
|
||||||
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
||||||
|
|
|
||||||
243
books/views.py
243
books/views.py
|
|
@ -1,9 +1,7 @@
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
@ -18,218 +16,6 @@ def _require_auth(request):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Library-shelf metadata lookup (DNB, falling back to Open Library)
|
|
||||||
#
|
|
||||||
# Only ever called on explicit user request (the "Metadaten" book action), never
|
|
||||||
# automatically — the server briefly sees the plaintext ISBN for this one proxied
|
|
||||||
# request, which is a deliberate, narrow exception to the "server never sees book
|
|
||||||
# content" rule (see CLAUDE.md), made because the encryption's real purpose here is
|
|
||||||
# to keep the operator from being able to see what's on the platform (piracy
|
|
||||||
# liability), not strict user privacy — an ISBN lookup against public library
|
|
||||||
# catalogs doesn't undermine that. Nothing from this lookup is persisted server-side;
|
|
||||||
# the resulting label is stored only in the client's encrypted meta blob.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# DNB "Sachgruppen der Deutschen Nationalbibliografie" / DDC divisions (hundreds -> tens),
|
|
||||||
# used to turn a raw DDC notation like "833.92" into a short shelf label.
|
|
||||||
_DDC_DIVISIONS = {
|
|
||||||
'000': 'Allgemeines, Informatik', '010': 'Bibliografien', '020': 'Bibliotheks- und Informationswissenschaft',
|
|
||||||
'030': 'Enzyklopädien', '050': 'Zeitschriften, fortlaufende Sammelwerke', '060': 'Organisationen, Museumswissenschaft',
|
|
||||||
'070': 'Nachrichtenmedien, Journalismus, Verlagswesen', '090': 'Handschriften, seltene Bücher',
|
|
||||||
'100': 'Philosophie', '130': 'Parapsychologie, Okkultismus', '150': 'Psychologie',
|
|
||||||
'200': 'Religion', '230': 'Christentum, Christliche Theologie', '290': 'Andere Religionen',
|
|
||||||
'300': 'Sozialwissenschaften, Soziologie', '310': 'Statistiken', '320': 'Politikwissenschaft',
|
|
||||||
'330': 'Wirtschaft', '340': 'Recht', '350': 'Öffentliche Verwaltung, Militärwissenschaft',
|
|
||||||
'360': 'Soziale Probleme, Sozialdienste, Versicherungen', '370': 'Erziehung, Schul- und Bildungswesen',
|
|
||||||
'380': 'Handel, Kommunikation, Verkehr', '390': 'Gebräuche, Etikette, Folklore',
|
|
||||||
'400': 'Sprache, Linguistik', '420': 'Englisch', '430': 'Deutsch, Germanische Sprachen',
|
|
||||||
'440': 'Französisch, Romanische Sprachen', '450': 'Italienisch, Rumänisch, Rätoromanisch',
|
|
||||||
'460': 'Spanisch, Portugiesisch', '470': 'Latein, Italische Sprachen', '480': 'Griechisch', '490': 'Andere Sprachen',
|
|
||||||
'500': 'Naturwissenschaften', '510': 'Mathematik', '520': 'Astronomie', '530': 'Physik', '540': 'Chemie',
|
|
||||||
'550': 'Geowissenschaften', '560': 'Paläontologie', '570': 'Biowissenschaften, Biologie',
|
|
||||||
'580': 'Pflanzen (Botanik)', '590': 'Tiere (Zoologie)',
|
|
||||||
'600': 'Technik', '610': 'Medizin, Gesundheit', '620': 'Ingenieurwissenschaften', '630': 'Landwirtschaft',
|
|
||||||
'640': 'Hauswirtschaft', '650': 'Management', '660': 'Chemische Technik', '670': 'Industrielle Fertigung',
|
|
||||||
'680': 'Fertigung für spezielle Zwecke', '690': 'Hausbau, Bauhandwerk',
|
|
||||||
'700': 'Künste', '710': 'Landschaftsgestaltung, Raumplanung', '720': 'Architektur',
|
|
||||||
'730': 'Plastik, Keramik, Metallkunst', '740': 'Zeichnung, angewandte Kunst', '750': 'Malerei',
|
|
||||||
'760': 'Grafik, Druckgrafik, Fotografie', '780': 'Musik', '790': 'Freizeit, Darstellende Kunst, Sport',
|
|
||||||
'800': 'Literatur', '810': 'Amerikanische Literatur', '820': 'Englische Literatur',
|
|
||||||
'830': 'Deutsche Literatur', '840': 'Französische Literatur', '850': 'Italienische Literatur',
|
|
||||||
'860': 'Spanische, Portugiesische Literatur', '870': 'Lateinische Literatur', '880': 'Griechische Literatur',
|
|
||||||
'890': 'Literaturen in anderen Sprachen',
|
|
||||||
'900': 'Geschichte', '910': 'Geografie, Reisen', '920': 'Biografie, Genealogie',
|
|
||||||
'930': 'Geschichte des Altertums', '940': 'Geschichte Europas', '950': 'Geschichte Asiens',
|
|
||||||
'960': 'Geschichte Afrikas', '970': 'Geschichte Nordamerikas', '980': 'Geschichte Südamerikas',
|
|
||||||
'990': 'Geschichte der übrigen Welt',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _ddc_label(ddc_raw):
|
|
||||||
"""Coarsen a DDC notation ('833.92') down to its nearest known division/class label."""
|
|
||||||
digits = re.sub(r'\D', '', ddc_raw or '')
|
|
||||||
if len(digits) < 3:
|
|
||||||
return None
|
|
||||||
tens = digits[:2] + '0'
|
|
||||||
if tens in _DDC_DIVISIONS:
|
|
||||||
return _DDC_DIVISIONS[tens]
|
|
||||||
return _DDC_DIVISIONS.get(digits[0] + '00')
|
|
||||||
|
|
||||||
|
|
||||||
_MARC_NS = '{http://www.loc.gov/MARC21/slim}'
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_dnb_shelf(isbn):
|
|
||||||
url = 'https://services.dnb.de/sru/dnb'
|
|
||||||
params = {
|
|
||||||
'version': '1.1',
|
|
||||||
'operation': 'searchRetrieve',
|
|
||||||
'query': f'dnb.num={isbn}',
|
|
||||||
'recordSchema': 'MARC21-xml',
|
|
||||||
'maximumRecords': '1',
|
|
||||||
}
|
|
||||||
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
||||||
resp.raise_for_status()
|
|
||||||
root = ET.fromstring(resp.content)
|
|
||||||
for datafield in root.iter(f'{_MARC_NS}datafield'):
|
|
||||||
if datafield.get('tag') != '082':
|
|
||||||
continue
|
|
||||||
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
|
|
||||||
if subfield.get('code') == 'a' and subfield.text:
|
|
||||||
label = _ddc_label(subfield.text)
|
|
||||||
if label:
|
|
||||||
return label
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_openlibrary_shelf(isbn):
|
|
||||||
url = 'https://openlibrary.org/api/books'
|
|
||||||
params = {'bibkeys': f'ISBN:{isbn}', 'jscmd': 'data', 'format': 'json'}
|
|
||||||
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json().get(f'ISBN:{isbn}', {})
|
|
||||||
ddc = (data.get('classifications') or {}).get('dewey_decimal_class') or []
|
|
||||||
if ddc:
|
|
||||||
label = _ddc_label(ddc[0])
|
|
||||||
if label:
|
|
||||||
return label
|
|
||||||
subjects = data.get('subjects') or []
|
|
||||||
if subjects:
|
|
||||||
return subjects[0].get('name')
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _cql_phrase(value):
|
|
||||||
# CQL string literals are quoted; strip embedded quotes rather than escaping them
|
|
||||||
# (this only feeds a lookup heuristic, not a stored/displayed value).
|
|
||||||
return value.replace('"', ' ').strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_dnb_shelf_by_title(title, author):
|
|
||||||
url = 'https://services.dnb.de/sru/dnb'
|
|
||||||
query = f'dnb.tit="{_cql_phrase(title)}"'
|
|
||||||
if author:
|
|
||||||
query += f' and dnb.per="{_cql_phrase(author)}"'
|
|
||||||
params = {
|
|
||||||
'version': '1.1',
|
|
||||||
'operation': 'searchRetrieve',
|
|
||||||
'query': query,
|
|
||||||
'recordSchema': 'MARC21-xml',
|
|
||||||
'maximumRecords': '1',
|
|
||||||
}
|
|
||||||
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
||||||
resp.raise_for_status()
|
|
||||||
root = ET.fromstring(resp.content)
|
|
||||||
for datafield in root.iter(f'{_MARC_NS}datafield'):
|
|
||||||
if datafield.get('tag') != '082':
|
|
||||||
continue
|
|
||||||
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
|
|
||||||
if subfield.get('code') == 'a' and subfield.text:
|
|
||||||
label = _ddc_label(subfield.text)
|
|
||||||
if label:
|
|
||||||
return label
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_openlibrary_shelf_by_title(title, author):
|
|
||||||
url = 'https://openlibrary.org/search.json'
|
|
||||||
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
|
|
||||||
if author:
|
|
||||||
params['author'] = author
|
|
||||||
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
||||||
resp.raise_for_status()
|
|
||||||
docs = resp.json().get('docs') or []
|
|
||||||
if not docs:
|
|
||||||
return None
|
|
||||||
doc = docs[0]
|
|
||||||
ddc = doc.get('ddc') or []
|
|
||||||
if ddc:
|
|
||||||
label = _ddc_label(ddc[0])
|
|
||||||
if label:
|
|
||||||
return label
|
|
||||||
subjects = doc.get('subject') or []
|
|
||||||
if subjects:
|
|
||||||
return subjects[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(['GET'])
|
|
||||||
def lookup_book_metadata(request):
|
|
||||||
err = _require_auth(request)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
isbn_raw = request.GET.get('isbn', '').strip()
|
|
||||||
title = request.GET.get('title', '').strip()
|
|
||||||
author = request.GET.get('author', '').strip()
|
|
||||||
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
|
|
||||||
|
|
||||||
if isbn_raw and len(isbn) not in (10, 13):
|
|
||||||
return JsonResponse({'error': 'invalid ISBN'}, status=400)
|
|
||||||
if not isbn and not title:
|
|
||||||
return JsonResponse({'error': 'isbn or title required'}, status=400)
|
|
||||||
|
|
||||||
label = None
|
|
||||||
source = None
|
|
||||||
|
|
||||||
# ISBN is the precise path — try it first when we have one.
|
|
||||||
if isbn:
|
|
||||||
try:
|
|
||||||
label = _lookup_dnb_shelf(isbn)
|
|
||||||
if label:
|
|
||||||
source = 'dnb'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not label:
|
|
||||||
try:
|
|
||||||
label = _lookup_openlibrary_shelf(isbn)
|
|
||||||
if label:
|
|
||||||
source = 'openlibrary'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
|
|
||||||
# precise (wrong edition/translation is possible), so the source is tagged
|
|
||||||
# distinctly for the client to hint at that if it wants to.
|
|
||||||
if not label and title:
|
|
||||||
try:
|
|
||||||
label = _lookup_dnb_shelf_by_title(title, author)
|
|
||||||
if label:
|
|
||||||
source = 'dnb-title'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not label:
|
|
||||||
try:
|
|
||||||
label = _lookup_openlibrary_shelf_by_title(title, author)
|
|
||||||
if label:
|
|
||||||
source = 'openlibrary-title'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return JsonResponse({'label': label, 'source': source})
|
|
||||||
|
|
||||||
|
|
||||||
def _anchor_parts(anchor):
|
def _anchor_parts(anchor):
|
||||||
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner).
|
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner).
|
||||||
|
|
||||||
|
|
@ -303,35 +89,6 @@ def set_book_read(request, pk):
|
||||||
return JsonResponse({'ok': True, 'is_read': book.is_read})
|
return JsonResponse({'ok': True, 'is_read': book.is_read})
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def update_book_meta(request, pk):
|
|
||||||
"""Update only the encrypted metadata blob (e.g. to assign a folder) without touching book bytes."""
|
|
||||||
err = _require_auth(request)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
try:
|
|
||||||
book = EBook.objects.get(pk=pk, user=request.user)
|
|
||||||
except EBook.DoesNotExist:
|
|
||||||
return JsonResponse({'error': 'not found'}, status=404)
|
|
||||||
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
||||||
|
|
||||||
meta_ct = body.get('meta_ct', '')
|
|
||||||
meta_iv = body.get('meta_iv', '')
|
|
||||||
if not meta_ct or not meta_iv:
|
|
||||||
return JsonResponse({'error': 'meta_ct, meta_iv required'}, status=400)
|
|
||||||
|
|
||||||
book.meta_ct = meta_ct
|
|
||||||
book.meta_iv = meta_iv
|
|
||||||
book.save(update_fields=['meta_ct', 'meta_iv'])
|
|
||||||
return JsonResponse({'ok': True})
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def upload_book(request):
|
def upload_book(request):
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""Cloud import endpoints for the ebook library.
|
|
||||||
|
|
||||||
These let the browser browse a user-configured WebDAV server and pull one book
|
|
||||||
out of it. The fetch is proxied through the server rather than done in the
|
|
||||||
browser because the WebDAV host is a different origin and will not send CORS
|
|
||||||
headers for it.
|
|
||||||
|
|
||||||
That proxy is a deliberate, narrow relaxation of the same rule the ISBN lookup
|
|
||||||
in books/views.py bends: the server sees the file's bytes in memory for the
|
|
||||||
duration of one request, but never persists them. The book is encrypted in the
|
|
||||||
browser and only then POSTed to /books/upload/ as ciphertext, exactly like a
|
|
||||||
local upload — nothing readable is ever stored server-side.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import HttpResponse, JsonResponse
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
|
|
||||||
from accounts.models import WebDAVSource
|
|
||||||
from accounts.webdav import WebDAVError, WebDAVInputError, list_directory, fetch_file
|
|
||||||
from .views import _require_auth
|
|
||||||
|
|
||||||
|
|
||||||
def _get_source(request, pk):
|
|
||||||
return WebDAVSource.objects.filter(pk=pk, user=request.user).first()
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(['GET'])
|
|
||||||
def cloud_sources(request):
|
|
||||||
unauthorized = _require_auth(request)
|
|
||||||
if unauthorized:
|
|
||||||
return unauthorized
|
|
||||||
|
|
||||||
sources = request.user.webdav_sources.all()
|
|
||||||
return JsonResponse({'sources': [
|
|
||||||
{'id': s.id, 'label': s.label, 'host': s.base_url}
|
|
||||||
for s in sources
|
|
||||||
]})
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(['GET'])
|
|
||||||
def cloud_browse(request, pk):
|
|
||||||
unauthorized = _require_auth(request)
|
|
||||||
if unauthorized:
|
|
||||||
return unauthorized
|
|
||||||
|
|
||||||
source = _get_source(request, pk)
|
|
||||||
if not source:
|
|
||||||
return JsonResponse({'error': 'unknown source'}, status=404)
|
|
||||||
|
|
||||||
path = request.GET.get('path', '')
|
|
||||||
try:
|
|
||||||
entries = list_directory(source, path)
|
|
||||||
except WebDAVInputError as exc:
|
|
||||||
return JsonResponse({'error': str(exc)}, status=400)
|
|
||||||
except WebDAVError as exc:
|
|
||||||
return JsonResponse({'error': str(exc)}, status=502)
|
|
||||||
|
|
||||||
source.last_used_at = timezone.now()
|
|
||||||
source.save(update_fields=['last_used_at'])
|
|
||||||
|
|
||||||
return JsonResponse({'ok': True, 'path': path, 'entries': entries})
|
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(['GET'])
|
|
||||||
def cloud_fetch(request, pk):
|
|
||||||
unauthorized = _require_auth(request)
|
|
||||||
if unauthorized:
|
|
||||||
return unauthorized
|
|
||||||
|
|
||||||
source = _get_source(request, pk)
|
|
||||||
if not source:
|
|
||||||
return JsonResponse({'error': 'unknown source'}, status=404)
|
|
||||||
|
|
||||||
path = request.GET.get('path', '')
|
|
||||||
try:
|
|
||||||
payload = fetch_file(source, path, settings.EBOOK_MAX_BYTES)
|
|
||||||
except WebDAVInputError as exc:
|
|
||||||
return JsonResponse({'error': str(exc)}, status=400)
|
|
||||||
except WebDAVError as exc:
|
|
||||||
return JsonResponse({'error': str(exc)}, status=502)
|
|
||||||
|
|
||||||
response = HttpResponse(payload, content_type='application/octet-stream')
|
|
||||||
# These are the book's plaintext bytes, on their way to be encrypted in the
|
|
||||||
# browser. Keeping them out of the HTTP disk cache (and any reverse proxy
|
|
||||||
# in front) is what makes "nothing readable is stored" true end to end.
|
|
||||||
response['Cache-Control'] = 'no-store'
|
|
||||||
return response
|
|
||||||
|
|
@ -30,7 +30,6 @@ INSTALLED_APPS = [
|
||||||
'podcasts',
|
'podcasts',
|
||||||
'books',
|
'books',
|
||||||
'gpodder',
|
'gpodder',
|
||||||
'tts',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||||
|
|
@ -98,23 +97,7 @@ STATIC_URL = '/static/'
|
||||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
# Django 5.1 removed STATICFILES_STORAGE in favour of STORAGES. The old setting
|
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||||
# sat here being silently ignored, so whitenoise fell back to plain
|
|
||||||
# StaticFilesStorage and served everything uncompressed — app.js went out at
|
|
||||||
# 251 KB instead of 62 KB on every cold load.
|
|
||||||
#
|
|
||||||
# The hashed filenames this also generates are inert: no template uses
|
|
||||||
# {% static %}, they all hardcode /static/… paths. Cache busting comes from the
|
|
||||||
# service worker's CACHE version instead (static/js/sw.js), which is why it is
|
|
||||||
# bumped on each release.
|
|
||||||
STORAGES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.files.storage.FileSystemStorage',
|
|
||||||
},
|
|
||||||
'staticfiles': {
|
|
||||||
'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
@ -123,11 +106,8 @@ BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB
|
HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB
|
||||||
BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
|
BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
|
||||||
|
|
||||||
VOLUME_DEFAULT = 102 # out of 255 (40%)
|
VOLUME_DEFAULT = 204 # out of 255
|
||||||
ITUNES_TIMEOUT = 6 # seconds
|
ITUNES_TIMEOUT = 6 # seconds
|
||||||
BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup)
|
|
||||||
WEBDAV_TIMEOUT = 15 # seconds (cloud import browse/fetch)
|
|
||||||
WEBDAV_MAX_SOURCES_PER_USER = 10
|
|
||||||
PODCAST_INBOX_PAGE_SIZE = 200
|
PODCAST_INBOX_PAGE_SIZE = 200
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
@ -144,18 +124,4 @@ LASTFM_API_SECRET = os.environ.get('LASTFM_API_SECRET', '')
|
||||||
AMAZON_AFFILIATE_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
|
AMAZON_AFFILIATE_TAG = os.environ.get('AMAZON_AFFILIATE_TAG', 'diora-20')
|
||||||
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
|
AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == 'True'
|
||||||
|
|
||||||
# WebDAV cloud import (ebooks). Users supply the target URL, so by default the
|
|
||||||
# server refuses to talk to non-public addresses — otherwise any registered
|
|
||||||
# account could probe the internal network through it. Set to True only when
|
|
||||||
# every account on this instance is trusted and the WebDAV server is on the LAN.
|
|
||||||
WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False') == 'True'
|
|
||||||
|
|
||||||
BUILD_TIME = os.environ.get('BUILD_TIME', '')
|
BUILD_TIME = os.environ.get('BUILD_TIME', '')
|
||||||
|
|
||||||
# Piper voice models for the reader's read-aloud feature (see tts/piper_engine.py).
|
|
||||||
TTS_VOICES = {
|
|
||||||
'de': os.environ.get(
|
|
||||||
'TTS_MODEL_PATH_DE', str(BASE_DIR / 'tts_models' / 'de_DE-thorsten-medium.onnx')),
|
|
||||||
'en': os.environ.get(
|
|
||||||
'TTS_MODEL_PATH_EN', str(BASE_DIR / 'tts_models' / 'en_US-lessac-medium.onnx')),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ urlpatterns = [
|
||||||
path('accounts/', include('accounts.urls')),
|
path('accounts/', include('accounts.urls')),
|
||||||
path('podcasts/', include('podcasts.urls')),
|
path('podcasts/', include('podcasts.urls')),
|
||||||
path('books/', include('books.urls')),
|
path('books/', include('books.urls')),
|
||||||
path('tts/', include('tts.urls')),
|
|
||||||
path('api/2/', include('gpodder.urls')),
|
path('api/2/', include('gpodder.urls')),
|
||||||
path('api/sync/', sync_snapshot, name='api_sync'),
|
path('api/sync/', sync_snapshot, name='api_sync'),
|
||||||
# Served at the root (not /static/js/sw.js) so its default scope covers
|
# Served at the root (not /static/js/sw.js) so its default scope covers
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,4 @@ urlpatterns = [
|
||||||
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
|
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
|
||||||
path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
|
path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
|
||||||
path('radio/stream-player/', views.stream_player, name='stream_player'),
|
path('radio/stream-player/', views.stream_player, name='stream_player'),
|
||||||
path('radio/creamfresh-stream/', views.creamfresh_stream, name='creamfresh_stream'),
|
|
||||||
path('radio/creamfresh-feedback/', views.creamfresh_feedback, name='creamfresh_feedback'),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -581,74 +581,6 @@ def import_m3u(request):
|
||||||
# Minimal HTTP stream player (standalone tab for mixed-content streams)
|
# Minimal HTTP stream player (standalone tab for mixed-content streams)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# creamfresh radio proxy
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Held server-side only -- never exposed to the browser this way. The
|
|
||||||
# upstream is protected by HTTP Basic Auth; a plain <audio src="user:pass@..">
|
|
||||||
# doesn't get credentials honoured consistently across browsers, so this
|
|
||||||
# transparently relays the request instead (including the client's own
|
|
||||||
# Icy-MetaData header, so the existing icy.py-based metadata SSE keeps
|
|
||||||
# working unmodified when pointed at this URL instead of the direct one).
|
|
||||||
CREAMFRESH_STREAM_URL = 'https://radio.creamfresh.xyz/stream.mp3'
|
|
||||||
CREAMFRESH_AUTH = ('player', 'MJMr58p83zAU5zZaDGU0_BIn')
|
|
||||||
|
|
||||||
|
|
||||||
def creamfresh_stream(request):
|
|
||||||
headers = {}
|
|
||||||
if request.META.get('HTTP_ICY_METADATA'):
|
|
||||||
headers['Icy-MetaData'] = request.META['HTTP_ICY_METADATA']
|
|
||||||
try:
|
|
||||||
upstream = requests.get(
|
|
||||||
CREAMFRESH_STREAM_URL,
|
|
||||||
auth=CREAMFRESH_AUTH,
|
|
||||||
headers=headers,
|
|
||||||
stream=True,
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
except requests.RequestException:
|
|
||||||
return HttpResponse(status=502)
|
|
||||||
|
|
||||||
response = StreamingHttpResponse(
|
|
||||||
upstream.iter_content(chunk_size=4096),
|
|
||||||
content_type=upstream.headers.get('Content-Type', 'audio/mpeg'),
|
|
||||||
status=upstream.status_code,
|
|
||||||
)
|
|
||||||
for h in ('icy-metaint', 'icy-name', 'icy-genre', 'icy-br', 'icy-description', 'icy-url'):
|
|
||||||
if h in upstream.headers:
|
|
||||||
response[h] = upstream.headers[h]
|
|
||||||
response['Cache-Control'] = 'no-cache'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def creamfresh_feedback(request):
|
|
||||||
"""Relays a thumbs up/down to the creamfresh DJ's own /dj/feedback --
|
|
||||||
same server-side-credentials reasoning as creamfresh_stream above."""
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
||||||
|
|
||||||
vote = body.get('vote')
|
|
||||||
if vote not in ('up', 'down'):
|
|
||||||
return JsonResponse({'error': "vote must be 'up' or 'down'"}, status=400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
upstream = requests.post(
|
|
||||||
'https://radio.creamfresh.xyz/dj/feedback',
|
|
||||||
auth=CREAMFRESH_AUTH,
|
|
||||||
json={'vote': vote},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
except requests.RequestException:
|
|
||||||
return JsonResponse({'error': 'upstream unreachable'}, status=502)
|
|
||||||
|
|
||||||
return JsonResponse(upstream.json(), status=upstream.status_code, safe=False)
|
|
||||||
|
|
||||||
|
|
||||||
def stream_player(request):
|
def stream_player(request):
|
||||||
url = request.GET.get('url', '').strip()
|
url = request.GET.get('url', '').strip()
|
||||||
name = request.GET.get('name', '').strip()
|
name = request.GET.get('name', '').strip()
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,7 @@
|
||||||
# Pinned to exact versions. These are what production runs and what the test
|
django>=4.2
|
||||||
# suite is green against — with `>=` the image you get depends on the day you
|
pylast>=5.2
|
||||||
# build it, which is how a rebuild once swapped in a gunicorn that could not
|
requests>=2.31
|
||||||
# boot the gevent worker at all.
|
python-dotenv>=1.0
|
||||||
#
|
whitenoise>=6.6
|
||||||
# Nothing here updates on its own any more, so bump deliberately: change a
|
feedparser>=6.0
|
||||||
# version, let CI run, then deploy.
|
gevent>=24.0
|
||||||
Django==6.1
|
|
||||||
pylast==7.1.0
|
|
||||||
requests==2.34.2
|
|
||||||
python-dotenv==1.2.3
|
|
||||||
whitenoise==6.12.0
|
|
||||||
feedparser==6.0.14
|
|
||||||
gevent==26.8.0
|
|
||||||
# gunicorn 26.2.0 imports packaging.version in ggevent.py while declaring no
|
|
||||||
# dependencies of its own, so packaging has to be requested explicitly.
|
|
||||||
gunicorn==26.2.0
|
|
||||||
packaging==26.3
|
|
||||||
piper-tts==1.7.0
|
|
||||||
|
|
|
||||||
|
|
@ -710,161 +710,6 @@ a:hover {
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- WebDAV cloud connections (settings) --- */
|
|
||||||
|
|
||||||
.webdav-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-item-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
min-width: 0;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-field input {
|
|
||||||
background: var(--bg-alt);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
outline: none;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-field input:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.webdav-form .btn {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Cloud import browser (books tab) --- */
|
|
||||||
|
|
||||||
.cloud-browser {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-browser-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-browser-head select {
|
|
||||||
background: var(--bg-alt);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-path {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-entries {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-entry {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.4rem 0.25rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-entry:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-entry-name {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
word-break: break-all;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-entry-name:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloud-entry-size {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
@ -1636,12 +1481,6 @@ body.dnd-mode .timer-display {
|
||||||
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
|
padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tts-lang-select {
|
|
||||||
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
|
|
||||||
border: 1px solid var(--border, #444); border-radius: 4px;
|
|
||||||
padding: 2px 4px; font-size: 0.8rem; cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-marker-btn-mobile { display: none; }
|
.reader-marker-btn-mobile { display: none; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|
@ -1744,14 +1583,8 @@ body.dnd-mode .timer-display {
|
||||||
.book-progress {
|
.book-progress {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.book-item-meta-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.book-item-actions {
|
.book-item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1759,56 +1592,6 @@ body.dnd-mode .timer-display {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
.book-shelf-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--muted, #888);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Per-book "⋮" menu (everything except Open) --- */
|
|
||||||
.book-item-menu {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.book-item-menu-list {
|
|
||||||
display: none;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
z-index: 20;
|
|
||||||
min-width: 220px;
|
|
||||||
background: var(--surface, #111);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 4px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
.book-item-menu-list.open {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.book-menu-item {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--fg);
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.book-menu-item:hover {
|
|
||||||
background: var(--bg-row, rgba(255, 255, 255, 0.08));
|
|
||||||
}
|
|
||||||
.book-menu-item--danger {
|
|
||||||
color: var(--accent, #e63946);
|
|
||||||
}
|
|
||||||
.book-list-filter {
|
.book-list-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1819,64 +1602,6 @@ body.dnd-mode .timer-display {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Recently-read section (always pinned atop the books view, plain list style) --- */
|
|
||||||
.book-recent-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
padding-bottom: 14px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.book-recent-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted, #888);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Book folders (single level) --- */
|
|
||||||
.book-folder-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.book-folder-tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: none;
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: center;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.book-folder-tile-icon {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.book-folder-tile-name {
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.book-folder-tile-count {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted, #888);
|
|
||||||
}
|
|
||||||
.book-folder-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- PDF pages --- */
|
/* --- PDF pages --- */
|
||||||
.pdf-page-wrapper {
|
.pdf-page-wrapper {
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
|
|
@ -2203,12 +1928,6 @@ mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border
|
||||||
mark.reader-search-match.active { background:rgba(230,57,70,.7); }
|
mark.reader-search-match.active { background:rgba(230,57,70,.7); }
|
||||||
#rs-search-count { font-size:12px; min-width:50px; }
|
#rs-search-count { font-size:12px; min-width:50px; }
|
||||||
|
|
||||||
/* Read-aloud (TTS) */
|
|
||||||
mark.tts-current { background:rgba(230,57,70,.55); color:inherit; border-radius:2px; }
|
|
||||||
.tts-bar { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:14px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:8px 16px; box-shadow:0 4px 16px rgba(0,0,0,.5); z-index:600; }
|
|
||||||
.tts-bar button { background:none; border:none; color:inherit; font-size:16px; cursor:pointer; padding:2px 4px; line-height:1; }
|
|
||||||
.tts-bar button:hover { opacity:0.7; }
|
|
||||||
|
|
||||||
/* Bookmarks sidebar */
|
/* Bookmarks sidebar */
|
||||||
.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); }
|
.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); }
|
||||||
|
|
||||||
|
|
|
||||||
782
static/js/app.js
782
static/js/app.js
|
|
@ -9,10 +9,6 @@
|
||||||
// State
|
// State
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Hardcoded for now -- only creamfresh radio gets the vote buttons, since
|
|
||||||
// only its backend (the DJ) actually does anything with them.
|
|
||||||
const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/';
|
|
||||||
|
|
||||||
let currentStation = null; // { url, name, id } | null
|
let currentStation = null; // { url, name, id } | null
|
||||||
let currentTrack = '';
|
let currentTrack = '';
|
||||||
let sseSource = null;
|
let sseSource = null;
|
||||||
|
|
@ -180,12 +176,6 @@ function playStation(url, name, stationId) {
|
||||||
$('play-stop-btn').classList.add('playing');
|
$('play-stop-btn').classList.add('playing');
|
||||||
$('save-station-btn').style.display = '';
|
$('save-station-btn').style.display = '';
|
||||||
|
|
||||||
const isCreamfresh = url === CREAMFRESH_RADIO_URL;
|
|
||||||
$('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none';
|
|
||||||
$('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none';
|
|
||||||
$('creamfresh-vote-up-btn').classList.remove('active');
|
|
||||||
$('creamfresh-vote-down-btn').classList.remove('active');
|
|
||||||
|
|
||||||
startMetadataSSE(url);
|
startMetadataSSE(url);
|
||||||
startPlaySession(name, url);
|
startPlaySession(name, url);
|
||||||
maybeShowDonationHint(url, name);
|
maybeShowDonationHint(url, name);
|
||||||
|
|
@ -230,8 +220,6 @@ function stopPlayback(clearStation = true) {
|
||||||
$('play-stop-btn').textContent = '▶ Play';
|
$('play-stop-btn').textContent = '▶ Play';
|
||||||
$('play-stop-btn').classList.remove('playing');
|
$('play-stop-btn').classList.remove('playing');
|
||||||
$('save-station-btn').style.display = 'none';
|
$('save-station-btn').style.display = 'none';
|
||||||
$('creamfresh-vote-up-btn').style.display = 'none';
|
|
||||||
$('creamfresh-vote-down-btn').style.display = 'none';
|
|
||||||
$('affiliate-section').style.display = 'none';
|
$('affiliate-section').style.display = 'none';
|
||||||
|
|
||||||
stopPlaySession();
|
stopPlaySession();
|
||||||
|
|
@ -616,36 +604,6 @@ async function saveCurrentStation() {
|
||||||
await saveStation(data);
|
await saveStation(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// creamfresh radio: DJ feedback (hardcoded to this one station, see
|
|
||||||
// CREAMFRESH_RADIO_URL above)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function creamfreshVote(direction) {
|
|
||||||
const upBtn = $('creamfresh-vote-up-btn');
|
|
||||||
const downBtn = $('creamfresh-vote-down-btn');
|
|
||||||
upBtn.disabled = true;
|
|
||||||
downBtn.disabled = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/radio/creamfresh-feedback/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': getCsrfToken(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ vote: direction }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`feedback returned ${res.status}`);
|
|
||||||
upBtn.classList.toggle('active', direction === 'up');
|
|
||||||
downBtn.classList.toggle('active', direction === 'down');
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('creamfresh vote failed', err);
|
|
||||||
} finally {
|
|
||||||
upBtn.disabled = false;
|
|
||||||
downBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveStation(station) {
|
async function saveStation(station) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/radio/save/', {
|
const res = await fetch('/radio/save/', {
|
||||||
|
|
@ -3291,11 +3249,11 @@ async function loadBookList() {
|
||||||
try {
|
try {
|
||||||
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||||||
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
||||||
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || ''};
|
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'};
|
||||||
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
|
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'};
|
||||||
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If local cache is further ahead than the server, push it and use it for display.
|
// If local cache is further ahead than the server, push it and use it for display.
|
||||||
|
|
@ -3334,7 +3292,7 @@ async function loadBookList() {
|
||||||
if (b.last_read) return 1;
|
if (b.last_read) return 1;
|
||||||
return (b.uploaded_at || '').localeCompare(a.uploaded_at || '');
|
return (b.uploaded_at || '').localeCompare(a.uploaded_at || '');
|
||||||
});
|
});
|
||||||
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || ''};
|
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub'};
|
||||||
const uploadArea = $('book-upload-area');
|
const uploadArea = $('book-upload-area');
|
||||||
if (uploadArea) uploadArea.style.display = 'none';
|
if (uploadArea) uploadArea.style.display = 'none';
|
||||||
renderBookList(cachedBooks);
|
renderBookList(cachedBooks);
|
||||||
|
|
@ -3364,15 +3322,6 @@ function markBookBroken(bookId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _extractIsbnFromOpf(opfDoc) {
|
|
||||||
const identifiers = opfDoc.querySelectorAll('metadata > identifier, metadata > *|identifier');
|
|
||||||
for (const el of identifiers) {
|
|
||||||
const digits = (el.textContent || '').replace(/[^0-9Xx]/g, '');
|
|
||||||
if (digits.length === 10 || digits.length === 13) return digits;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function repairBook(bookId) {
|
function repairBook(bookId) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
|
|
@ -3389,7 +3338,7 @@ function repairBook(bookId) {
|
||||||
const isPdf = /\.pdf$/i.test(file.name);
|
const isPdf = /\.pdf$/i.test(file.name);
|
||||||
const type = isPdf ? 'pdf' : 'epub';
|
const type = isPdf ? 'pdf' : 'epub';
|
||||||
|
|
||||||
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
|
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '';
|
||||||
try {
|
try {
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
|
||||||
|
|
@ -3407,20 +3356,11 @@ function repairBook(bookId) {
|
||||||
await zip.file(opfPath).async('text'), 'application/xml');
|
await zip.file(opfPath).async('text'), 'application/xml');
|
||||||
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
|
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
|
||||||
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
|
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
|
||||||
isbn = _extractIsbnFromOpf(opfDoc);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { /* keep filename as title */ }
|
} catch (e) { /* keep filename as title */ }
|
||||||
|
|
||||||
// Preserve folder/shelfTag assigned before this repair — replacing the book's
|
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type}));
|
||||||
// data shouldn't silently wipe organization the user already set up.
|
|
||||||
const prevMeta = bookMetaCache[bookId] || {};
|
|
||||||
const metaJson = new TextEncoder().encode(JSON.stringify({
|
|
||||||
title, author, filename: file.name, type,
|
|
||||||
isbn: isbn || prevMeta.isbn || '',
|
|
||||||
folder: prevMeta.folder || '',
|
|
||||||
shelfTag: prevMeta.shelfTag || '',
|
|
||||||
}));
|
|
||||||
const [metaEnc, dataEnc] = await Promise.all([
|
const [metaEnc, dataEnc] = await Promise.all([
|
||||||
encryptBytes(key, metaJson),
|
encryptBytes(key, metaJson),
|
||||||
encryptBytes(key, buf),
|
encryptBytes(key, buf),
|
||||||
|
|
@ -3475,284 +3415,38 @@ async function toggleBookRead(bookId, currentlyRead) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the
|
|
||||||
// server never sees plaintext folder names — no schema change needed, and it rides along for
|
|
||||||
// free in /api/sync/. One level only: folders don't nest.
|
|
||||||
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
|
|
||||||
|
|
||||||
function _recentlyReadBooks(books) {
|
|
||||||
return books
|
|
||||||
.filter(b => b.last_read)
|
|
||||||
.sort((a, b) => b.last_read.localeCompare(a.last_read))
|
|
||||||
.slice(0, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderBookItemHtml(b) {
|
|
||||||
const pct = Math.round((b.scroll_fraction || 0) * 100);
|
|
||||||
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : '';
|
|
||||||
const broken = _brokenBooks.has(b.id);
|
|
||||||
return `<div class="book-item" data-book-id="${b.id}">
|
|
||||||
<div class="book-item-info">
|
|
||||||
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
|
|
||||||
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
|
||||||
<span class="book-item-meta-row">
|
|
||||||
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
|
||||||
${b.shelfTag ? `<span class="book-shelf-badge" title="Regal-Kategorie">${escapeHtml(b.shelfTag)}</span>` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="book-item-actions">
|
|
||||||
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
|
|
||||||
<div class="book-item-menu">
|
|
||||||
<button class="btn btn-sm book-menu-toggle" title="Weitere Optionen" onclick="toggleBookMenu(this)">⋮</button>
|
|
||||||
<div class="book-item-menu-list">
|
|
||||||
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
|
|
||||||
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen & Notizen herunterladen</button>` : ''}
|
|
||||||
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
|
|
||||||
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷️ Metadaten abrufen (Regal)</button>
|
|
||||||
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
|
|
||||||
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleBookMenu(toggleBtn) {
|
|
||||||
// Looked up relative to the clicked button (not by id) because the same book — and
|
|
||||||
// thus the same rendered menu — can appear twice at once: once in the "Zuletzt
|
|
||||||
// gelesen" section and again further down in the folder/unfiled list.
|
|
||||||
const menu = toggleBtn.closest('.book-item-menu')?.querySelector('.book-item-menu-list');
|
|
||||||
if (!menu) return;
|
|
||||||
const willOpen = !menu.classList.contains('open');
|
|
||||||
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
|
|
||||||
if (willOpen) menu.classList.add('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close any open book menu on an outside click, or right after an item inside it is
|
|
||||||
// clicked (the toggle button manages its own open/close state above, so it's excluded).
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.book-menu-toggle')) return;
|
|
||||||
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
|
|
||||||
});
|
|
||||||
|
|
||||||
function _openBookFolder(name) {
|
|
||||||
_currentBookFolder = name;
|
|
||||||
renderBookList(_lastBookListData);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBookList(books) {
|
function renderBookList(books) {
|
||||||
const listEl = $('book-list');
|
const listEl = $('book-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
_lastBookListData = books;
|
_lastBookListData = books;
|
||||||
|
|
||||||
if (!books.length) {
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
// Always-visible "recently read" section, independent of folder navigation and the
|
|
||||||
// read/unread filter below — it's a shortcut back into whatever was open last. Same
|
|
||||||
// vertical list-item look as the rest of the list (not a card shelf).
|
|
||||||
const recent = _recentlyReadBooks(books);
|
|
||||||
if (recent.length) {
|
|
||||||
html += '<div class="book-recent-section">'
|
|
||||||
+ '<h3 class="book-recent-title">Zuletzt gelesen</h3>'
|
|
||||||
+ recent.map(_renderBookItemHtml).join('')
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
|
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
|
||||||
if (!visible.length) {
|
if (!visible.length) {
|
||||||
html += books.length
|
listEl.innerHTML = books.length
|
||||||
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
|
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
|
||||||
: '';
|
: '';
|
||||||
listEl.innerHTML = html;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let html = '';
|
||||||
const grouped = new Map();
|
|
||||||
for (const b of visible) {
|
for (const b of visible) {
|
||||||
const key = b.folder || '';
|
const pct = Math.round((b.scroll_fraction || 0) * 100);
|
||||||
if (!grouped.has(key)) grouped.set(key, []);
|
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : '';
|
||||||
grouped.get(key).push(b);
|
const broken = _brokenBooks.has(b.id);
|
||||||
}
|
html += `<div class="book-item" data-book-id="${b.id}">
|
||||||
// Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root.
|
<div class="book-item-info">
|
||||||
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
|
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
|
||||||
_currentBookFolder = null;
|
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
||||||
}
|
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
||||||
|
</div>
|
||||||
if (_currentBookFolder === null) {
|
<div class="book-item-actions">
|
||||||
const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de'));
|
${broken ? `<button class="btn btn-sm btn-danger book-broken-btn" title="Buch konnte nicht geöffnet werden — Datei erneut hochladen" onclick="repairBook(${b.id})">!</button>` : ''}
|
||||||
if (folderNames.length) {
|
${b.has_highlights ? `<button class="btn btn-sm" title="Markierungen & Notizen herunterladen" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳</button>` : ''}
|
||||||
html += '<div class="book-folder-grid">'
|
<button class="btn btn-sm" title="${b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren'}" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺' : '✓'}</button>
|
||||||
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
|
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
|
||||||
<span class="book-folder-tile-icon">📁</span>
|
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
|
||||||
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
|
</div>
|
||||||
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
|
|
||||||
</button>`).join('')
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
html += (grouped.get('') || []).map(_renderBookItemHtml).join('');
|
|
||||||
} else {
|
|
||||||
html += `<div class="book-folder-header">
|
|
||||||
<button class="btn btn-sm" onclick="_openBookFolder(null)">← Alle Ordner</button>
|
|
||||||
<strong>${escapeHtml(_currentBookFolder)}</strong>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listEl.innerHTML = html;
|
listEl.innerHTML = html;
|
||||||
listEl.querySelectorAll('.book-folder-tile').forEach(tile => {
|
|
||||||
tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given
|
|
||||||
// field overrides applied on top of whatever is already cached, and pushes it to the server.
|
|
||||||
async function _updateBookMeta(bookId, overrides) {
|
|
||||||
const book = _lastBookListData.find(b => b.id === bookId);
|
|
||||||
if (!book) return false;
|
|
||||||
const cached = bookMetaCache[bookId] || {};
|
|
||||||
const merged = {
|
|
||||||
title: cached.title || book.title,
|
|
||||||
author: cached.author || book.author,
|
|
||||||
filename: cached.filename || '',
|
|
||||||
type: cached.type || book.type || 'epub',
|
|
||||||
isbn: cached.isbn || book.isbn || '',
|
|
||||||
folder: cached.folder || book.folder || '',
|
|
||||||
shelfTag: cached.shelfTag || book.shelfTag || '',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
const key = await getOrCreateEncKey();
|
|
||||||
const metaJson = new TextEncoder().encode(JSON.stringify(merged));
|
|
||||||
const metaEnc = await encryptBytes(key, metaJson);
|
|
||||||
const res = await fetch(`/books/${bookId}/meta/`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
|
||||||
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!data.ok) throw new Error('Server error');
|
|
||||||
Object.assign(book, overrides);
|
|
||||||
bookMetaCache[bookId] = merged;
|
|
||||||
_saveBookMeta(_lastBookListData);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assignBookFolder(bookId) {
|
|
||||||
const book = _lastBookListData.find(b => b.id === bookId);
|
|
||||||
if (!book) return;
|
|
||||||
const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'));
|
|
||||||
const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : '';
|
|
||||||
const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || '');
|
|
||||||
if (result === null) return;
|
|
||||||
const folder = result.trim();
|
|
||||||
if (folder === (book.folder || '')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _updateBookMeta(bookId, {folder});
|
|
||||||
if (folder) _currentBookFolder = folder;
|
|
||||||
renderBookList(_lastBookListData);
|
|
||||||
} catch (e) {
|
|
||||||
await customAlert('Konnte Ordner nicht ändern: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for books uploaded before ISBN extraction existed (or repaired/re-encrypted
|
|
||||||
// without it): pulls the already-accessible encrypted book bytes, decrypts and unzips just
|
|
||||||
// far enough to read the OPF identifiers — same source openBook() itself parses — without
|
|
||||||
// doing a full reader render. EPUB only; PDFs never carried a reliable ISBN via pdf.js.
|
|
||||||
async function _extractIsbnFromBookFile(bookId) {
|
|
||||||
try {
|
|
||||||
const key = await getOrCreateEncKey();
|
|
||||||
let data_ct, data_iv;
|
|
||||||
const cached = await _getCachedBook(bookId);
|
|
||||||
if (cached) {
|
|
||||||
({data_ct, data_iv} = cached);
|
|
||||||
} else {
|
|
||||||
const res = await fetch(`/books/${bookId}/data/`);
|
|
||||||
({data_ct, data_iv} = await res.json());
|
|
||||||
}
|
|
||||||
const plain = await decryptBytes(key, data_iv, data_ct);
|
|
||||||
const zip = await JSZip.loadAsync(plain);
|
|
||||||
const containerXml = await zip.file('META-INF/container.xml').async('text');
|
|
||||||
const opfPath = new DOMParser()
|
|
||||||
.parseFromString(containerXml, 'application/xml')
|
|
||||||
.querySelector('rootfile')?.getAttribute('full-path');
|
|
||||||
if (!opfPath) return '';
|
|
||||||
const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
|
|
||||||
return _extractIsbnFromOpf(opfDoc);
|
|
||||||
} catch (e) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _fetchShelfLookup(isbn, title, author) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (isbn) params.set('isbn', isbn);
|
|
||||||
if (title) params.set('title', title);
|
|
||||||
if (author) params.set('author', author);
|
|
||||||
const res = await fetch(`/books/metadata-lookup/?${params.toString()}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually triggered (never automatic) — looks up which library shelf/DDC category this
|
|
||||||
// book falls under via the server-side DNB→Open Library proxy, purely as an organizational
|
|
||||||
// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is
|
|
||||||
// allowed to briefly touch the server with a plaintext ISBN/title/author.
|
|
||||||
//
|
|
||||||
// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g.
|
|
||||||
// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for
|
|
||||||
// EPUBs, (2) server-side title/author text search (less precise — could match the wrong
|
|
||||||
// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN
|
|
||||||
// by hand (from the book's cover/an online store) and retry once.
|
|
||||||
async function lookupBookMetadata(bookId) {
|
|
||||||
const book = _lastBookListData.find(b => b.id === bookId);
|
|
||||||
if (!book) return;
|
|
||||||
const cached = bookMetaCache[bookId] || {};
|
|
||||||
let isbn = cached.isbn || book.isbn || '';
|
|
||||||
const isPdf = (cached.type || book.type) === 'pdf';
|
|
||||||
const title = cached.title || book.title || '';
|
|
||||||
const author = cached.author || book.author || '';
|
|
||||||
|
|
||||||
if (!isbn && !isPdf) {
|
|
||||||
// Older upload without a stored ISBN — extract it from the book file itself (may take
|
|
||||||
// a moment for large books, since it downloads/decrypts the full file if not cached).
|
|
||||||
isbn = await _extractIsbnFromBookFile(bookId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let data = await _fetchShelfLookup(isbn, title, author);
|
|
||||||
|
|
||||||
if (!data.label) {
|
|
||||||
const manual = await customPrompt(
|
|
||||||
'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', ''
|
|
||||||
);
|
|
||||||
const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, '');
|
|
||||||
if (manualIsbn) {
|
|
||||||
isbn = manualIsbn;
|
|
||||||
data = await _fetchShelfLookup(isbn, title, author);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist whatever we ended up with either way, so a repeat lookup never needs to
|
|
||||||
// re-extract/re-search from scratch.
|
|
||||||
const overrides = {};
|
|
||||||
if (isbn) overrides.isbn = isbn;
|
|
||||||
if (data.label) overrides.shelfTag = data.label;
|
|
||||||
if (Object.keys(overrides).length) {
|
|
||||||
await _updateBookMeta(bookId, overrides);
|
|
||||||
renderBookList(_lastBookListData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.label) {
|
|
||||||
await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bookFileSelected(input) {
|
function bookFileSelected(input) {
|
||||||
|
|
@ -3818,33 +3512,24 @@ async function deriveAndStoreKey() {
|
||||||
|
|
||||||
async function uploadEbook(file) {
|
async function uploadEbook(file) {
|
||||||
const statusEl = $('book-upload-status');
|
const statusEl = $('book-upload-status');
|
||||||
if (file.size > DIORA_CONFIG.ebookMaxBytes) {
|
const isPdf = /\.pdf$/i.test(file.name);
|
||||||
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
const isEpub = /\.epub$/i.test(file.name);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared tail of every import path (local file, cloud): parse metadata, encrypt
|
|
||||||
// with the user's key and POST ciphertext. Plaintext never leaves the browser.
|
|
||||||
async function _importEbookBuffer(buf, filename, statusEl) {
|
|
||||||
const isPdf = /\.pdf$/i.test(filename);
|
|
||||||
const isEpub = /\.epub$/i.test(filename);
|
|
||||||
if (!isPdf && !isEpub) {
|
if (!isPdf && !isEpub) {
|
||||||
if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.';
|
if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.';
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) {
|
if (file.size > DIORA_CONFIG.ebookMaxBytes) {
|
||||||
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
if (statusEl) statusEl.textContent = `File too large (max ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusEl) statusEl.textContent = 'Encrypting…';
|
if (statusEl) statusEl.textContent = 'Encrypting…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let title = filename.replace(/\.(epub|pdf)$/i, '');
|
const buf = await file.arrayBuffer();
|
||||||
|
|
||||||
|
let title = file.name.replace(/\.(epub|pdf)$/i, '');
|
||||||
let author = '';
|
let author = '';
|
||||||
let isbn = '';
|
|
||||||
const type = isPdf ? 'pdf' : 'epub';
|
const type = isPdf ? 'pdf' : 'epub';
|
||||||
|
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
|
|
@ -3865,13 +3550,12 @@ async function _importEbookBuffer(buf, filename, statusEl) {
|
||||||
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
|
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
|
||||||
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
|
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
|
||||||
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
|
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
|
||||||
isbn = _extractIsbnFromOpf(opfDoc);
|
|
||||||
}
|
}
|
||||||
} catch (e) { /* use filename as title */ }
|
} catch (e) { /* use filename as title */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await getOrCreateEncKey();
|
const key = await getOrCreateEncKey();
|
||||||
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''}));
|
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type}));
|
||||||
const [metaEnc, dataEnc] = await Promise.all([
|
const [metaEnc, dataEnc] = await Promise.all([
|
||||||
encryptBytes(key, metaJson),
|
encryptBytes(key, metaJson),
|
||||||
encryptBytes(key, buf),
|
encryptBytes(key, buf),
|
||||||
|
|
@ -3893,165 +3577,11 @@ async function _importEbookBuffer(buf, filename, statusEl) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`;
|
if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`;
|
||||||
loadBookList();
|
loadBookList();
|
||||||
return true;
|
} else {
|
||||||
}
|
|
||||||
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
|
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
|
||||||
return false;
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message;
|
if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cloud import — browse a user-configured WebDAV server and pull a book in.
|
|
||||||
//
|
|
||||||
// The download is proxied by the server because the remote host is a different
|
|
||||||
// origin and sends no CORS headers. It still lands here as raw bytes and goes
|
|
||||||
// through _importEbookBuffer like any local file, so what gets stored is
|
|
||||||
// ciphertext the server cannot read.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let _cloudSources = [];
|
|
||||||
let _cloudSourceId = null;
|
|
||||||
let _cloudPath = '';
|
|
||||||
|
|
||||||
async function toggleCloudImport() {
|
|
||||||
const panel = $('cloud-browser');
|
|
||||||
if (!panel) return;
|
|
||||||
if (panel.style.display !== 'none') { closeCloudImport(); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/books/cloud/sources/');
|
|
||||||
_cloudSources = (await res.json()).sources || [];
|
|
||||||
} catch (e) {
|
|
||||||
await customAlert('Cloud-Verbindungen konnten nicht geladen werden: ' + e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_cloudSources.length) {
|
|
||||||
await customAlert(
|
|
||||||
'Noch keine Cloud-Verbindung eingerichtet. Unter Einstellungen → '
|
|
||||||
+ '„Cloud-Verbindungen für Bücher“ kannst du eine hinzufügen.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const select = $('cloud-source-select');
|
|
||||||
if (select) {
|
|
||||||
select.innerHTML = '';
|
|
||||||
for (const source of _cloudSources) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = source.id;
|
|
||||||
option.textContent = source.label;
|
|
||||||
select.appendChild(option);
|
|
||||||
}
|
|
||||||
select.style.display = _cloudSources.length > 1 ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.style.display = '';
|
|
||||||
_cloudSourceId = _cloudSources[0].id;
|
|
||||||
_cloudBrowse('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCloudImport() {
|
|
||||||
const panel = $('cloud-browser');
|
|
||||||
if (panel) panel.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _onCloudSourceChange(value) {
|
|
||||||
_cloudSourceId = parseInt(value, 10);
|
|
||||||
_cloudBrowse('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _cloudParentPath(path) {
|
|
||||||
const parts = path.split('/').filter(Boolean);
|
|
||||||
parts.pop();
|
|
||||||
return parts.join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _formatBytes(n) {
|
|
||||||
if (!n) return '';
|
|
||||||
if (n < 1024) return n + ' B';
|
|
||||||
if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB';
|
|
||||||
return (n / 1024 / 1024).toFixed(1) + ' MB';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _cloudEntryRow(label, onClick, sizeText) {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'cloud-entry';
|
|
||||||
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.className = 'cloud-entry-name';
|
|
||||||
button.textContent = label;
|
|
||||||
button.addEventListener('click', onClick);
|
|
||||||
li.appendChild(button);
|
|
||||||
|
|
||||||
if (sizeText) {
|
|
||||||
const size = document.createElement('span');
|
|
||||||
size.className = 'cloud-entry-size';
|
|
||||||
size.textContent = sizeText;
|
|
||||||
li.appendChild(size);
|
|
||||||
}
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _cloudBrowse(path) {
|
|
||||||
const listEl = $('cloud-entries');
|
|
||||||
const pathEl = $('cloud-path');
|
|
||||||
const statusEl = $('cloud-status');
|
|
||||||
if (!listEl) return;
|
|
||||||
|
|
||||||
if (statusEl) statusEl.textContent = 'Lade…';
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/books/cloud/${_cloudSourceId}/browse/?path=${encodeURIComponent(path)}`);
|
|
||||||
data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
||||||
} catch (e) {
|
|
||||||
if (statusEl) statusEl.textContent = 'Fehler: ' + e.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cloudPath = data.path || '';
|
|
||||||
if (pathEl) pathEl.textContent = '/' + _cloudPath;
|
|
||||||
if (statusEl) statusEl.textContent = '';
|
|
||||||
|
|
||||||
if (_cloudPath) {
|
|
||||||
listEl.appendChild(_cloudEntryRow('⬑ ..', () => _cloudBrowse(_cloudParentPath(_cloudPath))));
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = (data.entries || []).filter(e => e.is_dir || e.is_book);
|
|
||||||
if (!entries.length && statusEl) {
|
|
||||||
statusEl.textContent = 'Keine Bücher oder Ordner in diesem Verzeichnis.';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
listEl.appendChild(entry.is_dir
|
|
||||||
? _cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path))
|
|
||||||
: _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _cloudImport(entry) {
|
|
||||||
const statusEl = $('cloud-status');
|
|
||||||
if (statusEl) statusEl.textContent = `Lade „${entry.name}“…`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err.error || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const buf = await res.arrayBuffer();
|
|
||||||
if (await _importEbookBuffer(buf, entry.name, statusEl) && statusEl) {
|
|
||||||
statusEl.textContent = `✓ „${entry.name}“ importiert`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (statusEl) statusEl.textContent = 'Import fehlgeschlagen: ' + e.message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4178,239 +3708,6 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
|
||||||
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
|
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Read-aloud (TTS) — server-side Piper synthesis, one sentence per request.
|
|
||||||
// EPUB only (no block model for PDFs). Highlights the sentence currently
|
|
||||||
// playing and scrolls it into view; playback advances sentence-by-sentence,
|
|
||||||
// then block-by-block, until stopped or the book ends.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin
|
|
||||||
|
|
||||||
let ttsActive = false;
|
|
||||||
let ttsPaused = false;
|
|
||||||
let ttsRunToken = 0;
|
|
||||||
let ttsAudio = null;
|
|
||||||
let ttsCurrentMark = null;
|
|
||||||
let ttsBarEl = null;
|
|
||||||
let ttsLang = localStorage.getItem('diora_tts_lang') || 'de';
|
|
||||||
|
|
||||||
function setTtsLang(lang) {
|
|
||||||
ttsLang = lang;
|
|
||||||
localStorage.setItem('diora_tts_lang', lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsSplitSentences(text) {
|
|
||||||
const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean);
|
|
||||||
const pieces = raw.length ? raw : [text];
|
|
||||||
const out = [];
|
|
||||||
for (const piece of pieces) {
|
|
||||||
let rest = piece;
|
|
||||||
while (rest.length > TTS_MAX_CHARS) {
|
|
||||||
let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS);
|
|
||||||
if (cut <= 0) cut = TTS_MAX_CHARS;
|
|
||||||
out.push(rest.slice(0, cut).trim());
|
|
||||||
rest = rest.slice(cut).trim();
|
|
||||||
}
|
|
||||||
if (rest) out.push(rest);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _ttsFetchAudio(sentence) {
|
|
||||||
const resp = await fetch('/tts/synthesize/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({text: sentence, lang: ttsLang}),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('tts request failed');
|
|
||||||
return await resp.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsClearHighlight() {
|
|
||||||
if (ttsCurrentMark && ttsCurrentMark.parentNode) {
|
|
||||||
const parent = ttsCurrentMark.parentNode;
|
|
||||||
while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark);
|
|
||||||
parent.removeChild(ttsCurrentMark);
|
|
||||||
parent.normalize();
|
|
||||||
}
|
|
||||||
ttsCurrentMark = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finds `sentence` as a substring of block.textContent starting at fromOffset
|
|
||||||
// (so repeated sentence text earlier in the block isn't matched again), turns
|
|
||||||
// it into a DOM Range via the same char-offset addressing highlights use, and
|
|
||||||
// wraps it in a <mark>. Returns the offset to resume searching from.
|
|
||||||
function _ttsHighlightSentence(block, sentence, fromOffset) {
|
|
||||||
_ttsClearHighlight();
|
|
||||||
const full = block.textContent;
|
|
||||||
const idx = full.indexOf(sentence, fromOffset);
|
|
||||||
if (idx === -1) return fromOffset;
|
|
||||||
const start = _nodeAtCharOffset(block, idx);
|
|
||||||
const end = _nodeAtCharOffset(block, idx + sentence.length);
|
|
||||||
if (!start || !end) return idx + sentence.length;
|
|
||||||
try {
|
|
||||||
const range = document.createRange();
|
|
||||||
range.setStart(start.node, start.offset);
|
|
||||||
range.setEnd(end.node, end.offset);
|
|
||||||
const mark = document.createElement('mark');
|
|
||||||
mark.className = 'tts-current';
|
|
||||||
range.surroundContents(mark);
|
|
||||||
ttsCurrentMark = mark;
|
|
||||||
_ttsScrollIntoView(mark);
|
|
||||||
} catch (e) {}
|
|
||||||
return idx + sentence.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsScrollIntoView(el) {
|
|
||||||
const contentEl = $('reader-content');
|
|
||||||
if (!contentEl) return;
|
|
||||||
const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
|
||||||
if (top < 40 || top > contentEl.clientHeight - 80) {
|
|
||||||
_suppressScrollJumpDetect();
|
|
||||||
contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsPlayBlob(blob) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const audio = new Audio(url);
|
|
||||||
ttsAudio = audio;
|
|
||||||
const done = () => { URL.revokeObjectURL(url); resolve(); };
|
|
||||||
audio.addEventListener('ended', done);
|
|
||||||
audio.addEventListener('error', done);
|
|
||||||
if (!ttsPaused) audio.play().catch(done);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walks blocks/sentences from startBlockIndex onward, fetching one sentence
|
|
||||||
// ahead while the current one plays so there's no gap between them.
|
|
||||||
async function _ttsPlayLoop(blocks, startBlockIndex, runToken) {
|
|
||||||
function* sentenceStream() {
|
|
||||||
for (let bi = startBlockIndex; bi < blocks.length; bi++) {
|
|
||||||
const block = blocks[bi];
|
|
||||||
const text = (block.textContent || '').trim();
|
|
||||||
if (!text) continue;
|
|
||||||
for (const sentence of _ttsSplitSentences(text)) yield {block, sentence};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const iter = sentenceStream();
|
|
||||||
let cur = iter.next();
|
|
||||||
if (cur.done) { stopReadAloud(); return; }
|
|
||||||
let curFetch = _ttsFetchAudio(cur.value.sentence);
|
|
||||||
let lastBlock = null;
|
|
||||||
let blockSearchOffset = 0;
|
|
||||||
|
|
||||||
while (!cur.done) {
|
|
||||||
if (runToken !== ttsRunToken) return;
|
|
||||||
const {block, sentence} = cur.value;
|
|
||||||
const next = iter.next();
|
|
||||||
const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null);
|
|
||||||
|
|
||||||
let audioBlob;
|
|
||||||
try {
|
|
||||||
audioBlob = await curFetch;
|
|
||||||
} catch (e) {
|
|
||||||
stopReadAloud();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (runToken !== ttsRunToken) return;
|
|
||||||
|
|
||||||
if (block !== lastBlock) { lastBlock = block; blockSearchOffset = 0; }
|
|
||||||
blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset);
|
|
||||||
|
|
||||||
await _ttsPlayBlob(audioBlob);
|
|
||||||
if (runToken !== ttsRunToken) return;
|
|
||||||
|
|
||||||
cur = next;
|
|
||||||
curFetch = nextFetch;
|
|
||||||
}
|
|
||||||
if (runToken === ttsRunToken) stopReadAloud();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsTogglePause() {
|
|
||||||
ttsPaused = !ttsPaused;
|
|
||||||
if (ttsAudio) {
|
|
||||||
if (ttsPaused) ttsAudio.pause();
|
|
||||||
else ttsAudio.play().catch(() => {});
|
|
||||||
}
|
|
||||||
_ttsUpdateBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsUpdateBar() {
|
|
||||||
if (!ttsBarEl) return;
|
|
||||||
const btn = ttsBarEl.querySelector('.tts-pause-btn');
|
|
||||||
if (btn) btn.textContent = ttsPaused ? '▶' : '⏸';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsShowBar() {
|
|
||||||
_ttsRemoveBar();
|
|
||||||
const bar = document.createElement('div');
|
|
||||||
bar.className = 'tts-bar';
|
|
||||||
bar.innerHTML = `
|
|
||||||
<button type="button" class="tts-pause-btn" title="Pause/Weiter">⏸</button>
|
|
||||||
<button type="button" class="tts-stop-btn" title="Vorlesen beenden">■</button>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(bar);
|
|
||||||
ttsBarEl = bar;
|
|
||||||
bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause);
|
|
||||||
bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ttsRemoveBar() {
|
|
||||||
if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleReadAloud() {
|
|
||||||
if (ttsActive) stopReadAloud();
|
|
||||||
else startReadAloud();
|
|
||||||
}
|
|
||||||
|
|
||||||
function startReadAloud() {
|
|
||||||
if (ttsActive) return;
|
|
||||||
if (currentPdfDoc) {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'reader-toast';
|
|
||||||
toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.';
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
setTimeout(() => toast.remove(), 2200);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const contentEl = $('reader-content');
|
|
||||||
if (!contentEl) return;
|
|
||||||
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
|
|
||||||
if (!blocks.length) return;
|
|
||||||
|
|
||||||
const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl));
|
|
||||||
const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0;
|
|
||||||
|
|
||||||
ttsActive = true;
|
|
||||||
ttsPaused = false;
|
|
||||||
ttsRunToken++;
|
|
||||||
const runToken = ttsRunToken;
|
|
||||||
|
|
||||||
const btn = $('reader-tts-btn');
|
|
||||||
if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; }
|
|
||||||
_ttsShowBar();
|
|
||||||
|
|
||||||
_ttsPlayLoop(blocks, startIndex, runToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopReadAloud() {
|
|
||||||
ttsRunToken++;
|
|
||||||
ttsActive = false;
|
|
||||||
ttsPaused = false;
|
|
||||||
if (ttsAudio) {
|
|
||||||
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
|
|
||||||
ttsAudio = null;
|
|
||||||
}
|
|
||||||
_ttsClearHighlight();
|
|
||||||
_ttsRemoveBar();
|
|
||||||
const btn = $('reader-tts-btn');
|
|
||||||
if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Immersive reader mode — tap centre of screen to toggle bars
|
// Immersive reader mode — tap centre of screen to toggle bars
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -4418,7 +3715,7 @@ let _immBarsVisible = true;
|
||||||
|
|
||||||
function _immHandleTap(e) {
|
function _immHandleTap(e) {
|
||||||
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
|
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
|
||||||
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return;
|
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet')) return;
|
||||||
// In marker mode, taps have a dedicated meaning (highlight/create a note) —
|
// In marker mode, taps have a dedicated meaning (highlight/create a note) —
|
||||||
// don't also toggle the immersive bars underneath.
|
// don't also toggle the immersive bars underneath.
|
||||||
if (markerModeActive) return;
|
if (markerModeActive) return;
|
||||||
|
|
@ -4817,7 +4114,6 @@ async function saveReaderProgress(force = false) {
|
||||||
|
|
||||||
function closeReader() {
|
function closeReader() {
|
||||||
exitReaderImmersiveMode();
|
exitReaderImmersiveMode();
|
||||||
stopReadAloud();
|
|
||||||
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
||||||
saveReaderProgress();
|
saveReaderProgress();
|
||||||
if (bookmarksDirty) saveBookmarks();
|
if (bookmarksDirty) saveBookmarks();
|
||||||
|
|
@ -6817,10 +6113,6 @@ function openRadioSidebar() {
|
||||||
setVolume(vol);
|
setVolume(vol);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore persisted read-aloud language
|
|
||||||
const ttsLangSelect = $('reader-tts-lang');
|
|
||||||
if (ttsLangSelect) ttsLangSelect.value = ttsLang;
|
|
||||||
|
|
||||||
// Load recommendations on page load
|
// Load recommendations on page load
|
||||||
loadRecommendations();
|
loadRecommendations();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v43';
|
const CACHE = 'diora-v35';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
@ -53,7 +53,6 @@ self.addEventListener('fetch', function (event) {
|
||||||
if (url.pathname.startsWith('/radio/sse/') ||
|
if (url.pathname.startsWith('/radio/sse/') ||
|
||||||
url.pathname.startsWith('/radio/record/') ||
|
url.pathname.startsWith('/radio/record/') ||
|
||||||
url.pathname.startsWith('/radio/affiliate/') ||
|
url.pathname.startsWith('/radio/affiliate/') ||
|
||||||
url.pathname.startsWith('/tts/') ||
|
|
||||||
url.pathname.startsWith('/admin/') ||
|
url.pathname.startsWith('/admin/') ||
|
||||||
url.pathname.startsWith('/podcasts/progress/') ||
|
url.pathname.startsWith('/podcasts/progress/') ||
|
||||||
url.pathname.startsWith('/podcasts/queue/') ||
|
url.pathname.startsWith('/podcasts/queue/') ||
|
||||||
|
|
|
||||||
|
|
@ -125,75 +125,6 @@
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Cloud connections (WebDAV / Nextcloud) for the ebook import -->
|
|
||||||
<section class="settings-section">
|
|
||||||
<h2>Cloud-Verbindungen für Bücher</h2>
|
|
||||||
<p class="lastfm-description">
|
|
||||||
Verbinde einen WebDAV-Server — Nextcloud, ownCloud, Synology oder was auch immer WebDAV
|
|
||||||
spricht — und importiere <code>.epub</code>- und <code>.pdf</code>-Dateien direkt daraus in
|
|
||||||
deine Bibliothek. Die Datei läuft dabei nur durch den Server hindurch; verschlüsselt wird sie
|
|
||||||
wie immer erst in deinem Browser, gespeichert wird ausschließlich der Geheimtext.
|
|
||||||
</p>
|
|
||||||
<p class="lastfm-description">
|
|
||||||
Lege dafür bitte ein <strong>App-Passwort</strong> an (bei Nextcloud unter
|
|
||||||
Einstellungen → Sicherheit) statt dein Konto-Passwort einzutragen — es wird serverseitig
|
|
||||||
gespeichert und lässt sich jederzeit einzeln widerrufen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if webdav_sources %}
|
|
||||||
<ul class="webdav-list">
|
|
||||||
{% for source in webdav_sources %}
|
|
||||||
<li class="webdav-item">
|
|
||||||
<div class="webdav-item-info">
|
|
||||||
<strong>{{ source.label }}</strong>
|
|
||||||
<span class="muted">{{ source.base_url }}{% if source.root_path %} · /{{ source.root_path }}{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
<div class="webdav-item-actions">
|
|
||||||
<form method="post" action="{% url 'webdav_test' source.pk %}" class="inline-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn">Testen</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="{% url 'webdav_delete' source.pk %}" class="inline-form"
|
|
||||||
onsubmit="return confirm('Verbindung „{{ source.label|escapejs }}“ entfernen?');">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn btn-danger">Entfernen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'webdav_add' %}" class="webdav-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label class="webdav-field">
|
|
||||||
<span>Name</span>
|
|
||||||
<input type="text" name="label" required placeholder="Meine Nextcloud">
|
|
||||||
</label>
|
|
||||||
<label class="webdav-field">
|
|
||||||
<span>Server-URL</span>
|
|
||||||
<input type="url" name="base_url" required placeholder="https://cloud.example.com">
|
|
||||||
</label>
|
|
||||||
<label class="webdav-field">
|
|
||||||
<span>Benutzername</span>
|
|
||||||
<input type="text" name="username" autocomplete="off" placeholder="dein-login">
|
|
||||||
</label>
|
|
||||||
<label class="webdav-field">
|
|
||||||
<span>App-Passwort</span>
|
|
||||||
<input type="password" name="password" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<label class="webdav-field">
|
|
||||||
<span>Unterordner <span class="muted">(optional)</span></span>
|
|
||||||
<input type="text" name="root_path" placeholder="Buecher">
|
|
||||||
</label>
|
|
||||||
<p class="lastfm-description" style="margin:0;">
|
|
||||||
Bei Nextcloud/ownCloud genügt die Server-Adresse — der WebDAV-Pfad wird aus dem
|
|
||||||
Benutzernamen ergänzt. Andere Server brauchen die vollständige WebDAV-URL.
|
|
||||||
</p>
|
|
||||||
<button type="submit" class="btn">Verbindung hinzufügen</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,10 @@
|
||||||
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">▶ Play</button>
|
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">▶ Play</button>
|
||||||
<label class="volume-label">
|
<label class="volume-label">
|
||||||
<span>vol</span>
|
<span>vol</span>
|
||||||
<input type="range" id="volume" min="0" max="255" value="102" class="volume-slider">
|
<input type="range" id="volume" min="0" max="255" value="204" class="volume-slider">
|
||||||
<input type="number" id="volume-num" min="0" max="255" value="102" class="volume-num">
|
<input type="number" id="volume-num" min="0" max="255" value="204" class="volume-num">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
||||||
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">👍</button>
|
|
||||||
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">👎</button>
|
|
||||||
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
|
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
|
||||||
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
|
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -321,16 +319,6 @@
|
||||||
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
||||||
<span id="book-upload-status" class="muted"></span>
|
<span id="book-upload-status" class="muted"></span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn" style="margin:0.5rem 0;" onclick="toggleCloudImport()">☁ Aus Cloud importieren</button>
|
|
||||||
<div id="cloud-browser" class="cloud-browser" style="display:none;">
|
|
||||||
<div class="cloud-browser-head">
|
|
||||||
<select id="cloud-source-select" onchange="_onCloudSourceChange(this.value)"></select>
|
|
||||||
<button type="button" class="btn" onclick="closeCloudImport()">Schließen</button>
|
|
||||||
</div>
|
|
||||||
<div id="cloud-path" class="cloud-path"></div>
|
|
||||||
<ul id="cloud-entries" class="cloud-entries"></ul>
|
|
||||||
<span id="cloud-status" class="muted"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="book-list-filter">
|
<label class="book-list-filter">
|
||||||
<input type="checkbox" id="book-show-read-toggle" onchange="_onBookShowReadToggle(this.checked)">
|
<input type="checkbox" id="book-show-read-toggle" onchange="_onBookShowReadToggle(this.checked)">
|
||||||
|
|
@ -356,11 +344,6 @@
|
||||||
</span>
|
</span>
|
||||||
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">⌕</button>
|
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">⌕</button>
|
||||||
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font & layout">⚙</button>
|
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font & layout">⚙</button>
|
||||||
<select id="reader-tts-lang" class="tts-lang-select" title="Vorlese-Sprache" onchange="setTtsLang(this.value)">
|
|
||||||
<option value="de">DE</option>
|
|
||||||
<option value="en">EN</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn-icon" id="reader-tts-btn" onclick="toggleReadAloud()" title="Vorlesen">▶</button>
|
|
||||||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
||||||
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">▤</button>
|
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">▤</button>
|
||||||
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class TtsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'tts'
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import io
|
|
||||||
import threading
|
|
||||||
import wave
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
DEFAULT_LANGUAGE = 'de'
|
|
||||||
SUPPORTED_LANGUAGES = tuple(settings.TTS_VOICES.keys())
|
|
||||||
|
|
||||||
# Lazy per-process singletons, one per language: each gunicorn worker loads a
|
|
||||||
# voice only once it's actually requested, rather than all workers loading
|
|
||||||
# every model at startup (the host runs several other containers with limited
|
|
||||||
# spare RAM). One lock guards both the lazy-load and the inference call below
|
|
||||||
# — a single self-hosted user never needs concurrent synthesis across
|
|
||||||
# languages, so there's no reason for a lock per voice.
|
|
||||||
_voices = {}
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_voice(lang):
|
|
||||||
if lang not in _voices:
|
|
||||||
with _lock:
|
|
||||||
if lang not in _voices:
|
|
||||||
from piper import PiperVoice
|
|
||||||
_voices[lang] = PiperVoice.load(str(settings.TTS_VOICES[lang]))
|
|
||||||
return _voices[lang]
|
|
||||||
|
|
||||||
|
|
||||||
def synthesize_wav(text, lang=DEFAULT_LANGUAGE):
|
|
||||||
"""Synthesize `text` (in `lang`) to WAV bytes.
|
|
||||||
|
|
||||||
Never persists or logs `text` — callers must not log it either. The lock
|
|
||||||
also serializes inference, since one onnxruntime session isn't meant to
|
|
||||||
run concurrent calls within a process.
|
|
||||||
"""
|
|
||||||
voice = _get_voice(lang)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with _lock:
|
|
||||||
with wave.open(buf, 'wb') as wav_file:
|
|
||||||
voice.synthesize_wav(text, wav_file)
|
|
||||||
return buf.getvalue()
|
|
||||||
55
tts/tests.py
55
tts/tests.py
|
|
@ -1,55 +0,0 @@
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from . import piper_engine
|
|
||||||
|
|
||||||
|
|
||||||
class TtsSynthesizeTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
||||||
|
|
||||||
def test_requires_auth(self):
|
|
||||||
resp = self.client.post('/tts/synthesize/', {'text': 'Hallo'}, content_type='application/json')
|
|
||||||
self.assertEqual(resp.status_code, 401)
|
|
||||||
|
|
||||||
def test_rejects_empty_text(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.post('/tts/synthesize/', {'text': ' '}, content_type='application/json')
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
|
|
||||||
def test_rejects_text_over_limit(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.post(
|
|
||||||
'/tts/synthesize/', {'text': 'a' * 501}, content_type='application/json')
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
|
|
||||||
def test_rejects_invalid_json(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.post('/tts/synthesize/', 'not json', content_type='application/json')
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
|
|
||||||
@patch.object(piper_engine, 'synthesize_wav', return_value=b'RIFF....WAVEfmt fake')
|
|
||||||
def test_synthesizes_audio_default_lang(self, mock_synth):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.post(
|
|
||||||
'/tts/synthesize/', {'text': 'Hallo Welt.'}, content_type='application/json')
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
self.assertEqual(resp['Content-Type'], 'audio/wav')
|
|
||||||
self.assertEqual(b''.join(resp.streaming_content), b'RIFF....WAVEfmt fake')
|
|
||||||
mock_synth.assert_called_once_with('Hallo Welt.', 'de')
|
|
||||||
|
|
||||||
@patch.object(piper_engine, 'synthesize_wav', return_value=b'RIFF....WAVEfmt fake')
|
|
||||||
def test_synthesizes_audio_explicit_lang(self, mock_synth):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.post(
|
|
||||||
'/tts/synthesize/', {'text': 'Hello world.', 'lang': 'en'}, content_type='application/json')
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
mock_synth.assert_called_once_with('Hello world.', 'en')
|
|
||||||
|
|
||||||
def test_rejects_unsupported_lang(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
resp = self.client.post(
|
|
||||||
'/tts/synthesize/', {'text': 'Hallo', 'lang': 'fr'}, content_type='application/json')
|
|
||||||
self.assertEqual(resp.status_code, 400)
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('synthesize/', views.synthesize, name='tts_synthesize'),
|
|
||||||
]
|
|
||||||
57
tts/views.py
57
tts/views.py
|
|
@ -1,57 +0,0 @@
|
||||||
import json
|
|
||||||
|
|
||||||
from django.http import JsonResponse, StreamingHttpResponse
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
from gevent.threadpool import ThreadPool
|
|
||||||
|
|
||||||
from . import piper_engine
|
|
||||||
|
|
||||||
# One sentence per request, hard-capped — this is the load-bearing part of the
|
|
||||||
# "server never holds more than a small, transient snippet of book text"
|
|
||||||
# agreement (see CLAUDE.md), not just a client-side convention.
|
|
||||||
MAX_TEXT_LENGTH = 500
|
|
||||||
|
|
||||||
# Offloads the CPU-bound Piper inference off the gevent hub's event loop, so a
|
|
||||||
# synthesis call doesn't stall other concurrent greenlets (radio SSE, other
|
|
||||||
# requests) in the same worker the way a plain in-greenlet call would.
|
|
||||||
_synth_pool = ThreadPool(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_auth(request):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(['POST'])
|
|
||||||
def synthesize(request):
|
|
||||||
err = _require_auth(request)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
||||||
|
|
||||||
text = body.get('text', '')
|
|
||||||
if not isinstance(text, str) or not text.strip():
|
|
||||||
return JsonResponse({'error': 'text required'}, status=400)
|
|
||||||
if len(text) > MAX_TEXT_LENGTH:
|
|
||||||
return JsonResponse({'error': f'text exceeds {MAX_TEXT_LENGTH} characters'}, status=400)
|
|
||||||
|
|
||||||
lang = body.get('lang', piper_engine.DEFAULT_LANGUAGE)
|
|
||||||
if lang not in piper_engine.SUPPORTED_LANGUAGES:
|
|
||||||
return JsonResponse({'error': 'unsupported lang'}, status=400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
audio = _synth_pool.apply(piper_engine.synthesize_wav, (text, lang))
|
|
||||||
except Exception:
|
|
||||||
return JsonResponse({'error': 'synthesis failed'}, status=500)
|
|
||||||
|
|
||||||
response = StreamingHttpResponse(iter([audio]), content_type='audio/wav')
|
|
||||||
response['Cache-Control'] = 'no-store'
|
|
||||||
response['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
126
tui/README.md
Normal file
126
tui/README.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# diora-tui
|
||||||
|
|
||||||
|
EPUB-Reader im Terminal — die erste Stufe einer TUI-Version von diora.
|
||||||
|
|
||||||
|
Zeigt ein Buch als **eine durchgehende Ansicht** über alle Kapitel hinweg (wie diora's
|
||||||
|
Web-Reader), nicht Kapitel für Kapitel. Lesefortschritt wird als dieselbe
|
||||||
|
`"blockIndex:innerFraction"`-Positionsangabe geführt wie der Web-Reader (`books/models.py`,
|
||||||
|
`EBookProgress`) — `blockIndex` zählt dabei exakt wie `static/js/app.js`s
|
||||||
|
`EPUB_BLOCK_SELECTOR` (`p, h1-h6, li, blockquote, dt, dd, figcaption` + textbasierte
|
||||||
|
`div`s ohne Element-Kinder), fortlaufend über das ganze Buch. Dadurch ist die Position
|
||||||
|
zwischen TUI und Web-Reader direkt vergleichbar, und `diora-tui sync` kann Fortschritt in
|
||||||
|
beide Richtungen synchronisieren (siehe unten).
|
||||||
|
|
||||||
|
Fortschritt wird — genau wie im Web-Reader (`save_progress`) — nur vorwärts überschrieben
|
||||||
|
("furthest wins"), lokal in `progress.json` (`~/.local/share/diora-tui/`, via
|
||||||
|
`platformdirs`), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene
|
||||||
|
Position zurücksetzt.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tui
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
diora-tui --library ~/Books # Standard: ~/Books
|
||||||
|
```
|
||||||
|
|
||||||
|
Tastenkürzel:
|
||||||
|
|
||||||
|
- `↑`/`k`, `↓`/`j` — zeilenweise scrollen
|
||||||
|
- `n` — nächstes Kapitel, `p` — vorheriges Kapitel
|
||||||
|
- `f` — Fußnote in der Nähe der aktuellen Position anzeigen (Peek-Overlay, `Escape`/`f`/`q`
|
||||||
|
zum Schließen); Erkennung folgt derselben Heuristik wie `app.js`s
|
||||||
|
`_looksLikeFootnoteLink` (Link in/um `<sup>`, Klassenname mit note/footnote/fn, oder
|
||||||
|
`epub:type="noteref"`)
|
||||||
|
- `Enter` — markiertes Buch aus der Bibliothek öffnen
|
||||||
|
- `r` (in der Bibliothek) — gelesene Bücher ein-/ausblenden (siehe unten)
|
||||||
|
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek)
|
||||||
|
|
||||||
|
Der Fließtext ist auf 120 Zeichen Breite begrenzt und horizontal zentriert (lesbarer als
|
||||||
|
volle Terminalbreite bei breiten Fenstern).
|
||||||
|
|
||||||
|
Unten rechts zeigt eine Statusleiste Akkustand (falls vorhanden, via `psutil`) und Uhrzeit,
|
||||||
|
sekündlich aktualisiert, auf Bibliotheks- und Reader-Ansicht.
|
||||||
|
|
||||||
|
Die Bibliotheksansicht ist nach zuletzt geöffnetem Buch sortiert (neueste zuerst; anhand
|
||||||
|
des Zeitstempels der zuletzt gespeicherten Position), und blendet gelesene Bücher
|
||||||
|
standardmäßig aus (`EBook.is_read` aus dem Sync-Snapshot, lokal in `library.json`
|
||||||
|
gespiegelt) — `r` zeigt sie wieder an, für diese Sitzung.
|
||||||
|
|
||||||
|
## Bücher + Fortschritt vom Server holen (`diora-tui sync`)
|
||||||
|
|
||||||
|
Sobald einmal Zugangsdaten gespeichert sind (`~/.config/diora-tui/config.json`, siehe
|
||||||
|
unten), synct `diora-tui` **automatisch** — einmal leise im Hintergrund beim Start (neue
|
||||||
|
Bücher + Fortschritt werden nachgeladen, die Bibliotheksliste aktualisiert sich von
|
||||||
|
selbst) und einmal beim Beenden über `q` (kurzer Moment Verzögerung, bevor die App
|
||||||
|
tatsächlich schließt). Der explizite Befehl ist für's Ersteinrichten und für
|
||||||
|
Nicht-interaktive Nutzung (Cron o.ä.):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage
|
||||||
|
diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern
|
||||||
|
```
|
||||||
|
|
||||||
|
Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books/<id>/data/` herunter,
|
||||||
|
entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als
|
||||||
|
normale `.epub`-Dateien in `--library` ab (Dateiname `<id> - <Titel>.epub`) — von da an
|
||||||
|
funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden
|
||||||
|
beim nächsten Lauf übersprungen (kein erneuter Download). Der Fortschritt aus dem Snapshot
|
||||||
|
wird dabei ebenfalls übernommen (nur vorwärts, wie lokal auch).
|
||||||
|
|
||||||
|
Während ein so heruntergeladenes Buch geöffnet ist (erkennbar am `<id> - `-Dateinamens-
|
||||||
|
Präfix), schickt der Reader Fortschritts-Updates zusätzlich zurück an den Server
|
||||||
|
(`POST /books/<id>/progress/`, `force: false` — überschreibt also nie eine weiter
|
||||||
|
gelesene Position, egal ob die vom Web-Reader oder einem anderen Gerät stammt). Rein
|
||||||
|
lokale Bücher (ohne dieses Präfix) bleiben unangetastet, kein Netzwerkzugriff.
|
||||||
|
|
||||||
|
Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter
|
||||||
|
`~/.config/diora-tui/config.json`, `chmod 600`):
|
||||||
|
|
||||||
|
- **Server-URL** — z.B. `https://diora.creamfresh.xyz`.
|
||||||
|
- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token".
|
||||||
|
- **Verschlüsselungs-Key** — der AES-256-Schlüssel, mit dem deine Bücher clientseitig
|
||||||
|
verschlüsselt wurden. Der `sync`-Prompt bietet zwei Wege:
|
||||||
|
1. **Aus Benutzername + Passwort ableiten** (Standard) — reproduziert exakt, was diora's
|
||||||
|
"Unlock with password"-Formular im Browser tut (PBKDF2-HMAC-SHA256, 200.000
|
||||||
|
Iterationen, Salt `"diora:" + username`; siehe `static/js/app.js:deriveAndStoreKey`).
|
||||||
|
Funktioniert nur, wenn der Account diesen Weg im Browser mindestens einmal benutzt
|
||||||
|
hat — sonst wurde der Key ursprünglich zufällig im Browser erzeugt, und diese
|
||||||
|
Ableitung trifft ihn nicht. `sync` meldet einen falschen Key als
|
||||||
|
Entschlüsselungsfehler (harmlos, kein Datenverlust), nie als falsches Ergebnis.
|
||||||
|
2. **Base64-Key direkt einfügen** — für den Fall, dass Weg 1 nicht passt. Der
|
||||||
|
Export-Button dafür ist im Browser-UI aktuell nicht verdrahtet (`exportEncKey()` in
|
||||||
|
`app.js` existiert, hat aber keinen sichtbaren Button); bis das nachgezogen ist, in
|
||||||
|
der Browser-Devtools-Konsole auf der diora-Seite (nicht `/accounts/settings/` — die
|
||||||
|
lädt `app.js` nicht) ausführen: `await exportEncKey()` — kopiert den Key ins
|
||||||
|
Clipboard, von dort ins `sync`-Prompt einfügen. Schlägt das mit einem
|
||||||
|
`NotAllowedError`/`InvalidAccessError` fehl, stattdessen direkt aus `localStorage`
|
||||||
|
lesen: `localStorage.getItem('diora_enc_key_' + window.USER_ID)`.
|
||||||
|
|
||||||
|
Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der
|
||||||
|
Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei.
|
||||||
|
|
||||||
|
## Performance bei großen Büchern
|
||||||
|
|
||||||
|
Sehr große Bücher (mehrstellige Tausend Absätze — z.B. Sammelbände) können beim
|
||||||
|
*ersten* Öffnen mehrere Sekunden bis niedrige zweistellige Sekunden brauchen (Parsing +
|
||||||
|
Zeilenumbruch-Berechnung für die durchgehende Ansicht). Ein zweites Öffnen desselben
|
||||||
|
Buchs bei gleicher Terminalbreite ist dank Cache (`~/.cache/diora-tui/layout_cache/`)
|
||||||
|
deutlich schneller. Das Rendering selbst skaliert nicht mit der Buchgröße — nur die
|
||||||
|
tatsächlich sichtbaren Zeilen werden gezeichnet (Textual Line API), nicht das ganze Buch
|
||||||
|
auf einmal.
|
||||||
|
|
||||||
|
## Grenzen der aktuellen Version
|
||||||
|
|
||||||
|
- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen,
|
||||||
|
wird gemeldet), da der Reader sie ohnehin nicht darstellen kann.
|
||||||
|
- Text wird als Fließtext ohne Bild-/Layout-Rendering dargestellt.
|
||||||
|
- `innerFraction` (die Position *innerhalb* eines Blocks) ist eine Terminal-Näherung
|
||||||
|
(zeilenbasiert statt pixelbasiert wie im Browser) — für die Fortschritts-Sortierung
|
||||||
|
zählt primär `blockIndex`, der exakt mit dem Web-Reader übereinstimmt; `innerFraction`
|
||||||
|
ist nur ein Tiebreaker innerhalb desselben Blocks.
|
||||||
1
tui/diora_tui/__init__.py
Normal file
1
tui/diora_tui/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""diora-tui: lokaler EPUB-Reader, Vorstufe eines diora-TUI-Clients."""
|
||||||
4
tui/diora_tui/__main__.py
Normal file
4
tui/diora_tui/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from diora_tui.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
48
tui/diora_tui/api.py
Normal file
48
tui/diora_tui/api.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for
|
||||||
|
local clients"). Status: that API is provisional — expect endpoint/field
|
||||||
|
changes as the server-side implementation settles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_sync_snapshot(server_url: str, token: str) -> dict:
|
||||||
|
resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_book_data(server_url: str, token: str, book_id: int) -> dict:
|
||||||
|
resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=120)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def post_progress(
|
||||||
|
server_url: str, token: str, book_id: int, *, scroll_fraction: float, position_anchor: str, force: bool = False
|
||||||
|
) -> None:
|
||||||
|
body = {"scroll_fraction": scroll_fraction, "position_anchor": position_anchor, "force": force}
|
||||||
|
resp = requests.post(
|
||||||
|
f"{server_url}/books/{book_id}/progress/", headers=_headers(token), json=body, timeout=30
|
||||||
|
)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)")
|
||||||
|
resp.raise_for_status()
|
||||||
231
tui/diora_tui/app.py
Normal file
231
tui/diora_tui/app.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""diora-tui: terminal EPUB reader for diora, syncing books + reading progress
|
||||||
|
against a diora server (see project README)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual import work
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Footer, Header, Label, ListItem, ListView
|
||||||
|
|
||||||
|
from . import config as config_mod
|
||||||
|
from . import crypto, epub, library_meta, progress, remote
|
||||||
|
from .reader_screen import ReaderScreen
|
||||||
|
from .statusbar import StatusBar
|
||||||
|
|
||||||
|
DEFAULT_LIBRARY = Path.home() / "Books"
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryScreen(Screen):
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("enter", "open_selected", "Öffnen"),
|
||||||
|
Binding("r", "toggle_read_filter", "Gelesene ein-/ausblenden"),
|
||||||
|
Binding("q", "quit", "Beenden"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, library_dir: Path) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.library_dir = library_dir
|
||||||
|
self.paths: list[Path] = []
|
||||||
|
self.visible_paths: list[Path] = []
|
||||||
|
self.show_read = False
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield ListView(id="library-list")
|
||||||
|
yield StatusBar()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.sub_title = str(self.library_dir)
|
||||||
|
self._refresh_list()
|
||||||
|
cfg = config_mod.load()
|
||||||
|
if cfg is not None:
|
||||||
|
self._auto_sync(cfg)
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _auto_sync(self, cfg: config_mod.RemoteConfig) -> None:
|
||||||
|
try:
|
||||||
|
result = remote.sync_library(self.library_dir, cfg)
|
||||||
|
except remote.SyncError:
|
||||||
|
return # best-effort — e.g. offline; the local library still works
|
||||||
|
self.app.call_from_thread(self._on_auto_sync_done, result)
|
||||||
|
|
||||||
|
def _on_auto_sync_done(self, result: remote.SyncResult) -> None:
|
||||||
|
if result.downloaded or result.progress_pulled:
|
||||||
|
self._refresh_list()
|
||||||
|
if result.downloaded:
|
||||||
|
self.notify(f"{len(result.downloaded)} neue(s) Buch/Bücher synchronisiert.", timeout=3)
|
||||||
|
|
||||||
|
def _refresh_list(self) -> None:
|
||||||
|
self.paths = epub.scan_library(self.library_dir)
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for path in self.paths:
|
||||||
|
book_id = epub._book_id(path)
|
||||||
|
read = library_meta.is_read(book_id)
|
||||||
|
if read and not self.show_read:
|
||||||
|
continue
|
||||||
|
saved = progress.load(book_id)
|
||||||
|
last_opened = saved.updated_at if saved else 0.0
|
||||||
|
entries.append((last_opened, path, read))
|
||||||
|
entries.sort(key=lambda e: e[0], reverse=True)
|
||||||
|
self.visible_paths = [path for _, path, _ in entries]
|
||||||
|
|
||||||
|
list_view = self.query_one("#library-list", ListView)
|
||||||
|
list_view.clear()
|
||||||
|
if not self.paths:
|
||||||
|
list_view.append(ListItem(Label(f"Keine EPUBs gefunden in {self.library_dir}")))
|
||||||
|
return
|
||||||
|
if not entries:
|
||||||
|
list_view.append(ListItem(Label("Alle Bücher als gelesen markiert — 'r' zum Anzeigen")))
|
||||||
|
return
|
||||||
|
for _, path, read in entries:
|
||||||
|
label = f"✓ {path.stem}" if read else path.stem
|
||||||
|
list_view.append(ListItem(Label(label)))
|
||||||
|
list_view.index = 0
|
||||||
|
list_view.focus()
|
||||||
|
|
||||||
|
def action_toggle_read_filter(self) -> None:
|
||||||
|
self.show_read = not self.show_read
|
||||||
|
self._refresh_list()
|
||||||
|
state = "eingeblendet" if self.show_read else "ausgeblendet"
|
||||||
|
self.notify(f"Gelesene Bücher {state}.", timeout=2)
|
||||||
|
|
||||||
|
def action_open_selected(self) -> None:
|
||||||
|
list_view = self.query_one("#library-list", ListView)
|
||||||
|
if not self.visible_paths or list_view.index is None:
|
||||||
|
return
|
||||||
|
self.app.push_screen(ReaderScreen(self.visible_paths[list_view.index]))
|
||||||
|
|
||||||
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
|
self.action_open_selected()
|
||||||
|
|
||||||
|
|
||||||
|
class DioraTuiApp(App):
|
||||||
|
CSS_PATH = "app.tcss"
|
||||||
|
TITLE = "diora-tui"
|
||||||
|
|
||||||
|
def __init__(self, library_dir: Path) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.library_dir = library_dir
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.push_screen(LibraryScreen(self.library_dir))
|
||||||
|
|
||||||
|
async def action_quit(self) -> None:
|
||||||
|
cfg = config_mod.load()
|
||||||
|
if cfg is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(remote.sync_library, self.library_dir, cfg)
|
||||||
|
except remote.SyncError:
|
||||||
|
pass # best-effort — don't block quitting on a sync failure
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(label: str, *, secret: bool = False) -> str:
|
||||||
|
value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip()
|
||||||
|
if not value:
|
||||||
|
raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_yes_no(question: str) -> bool:
|
||||||
|
return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja")
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_enc_key() -> str:
|
||||||
|
print()
|
||||||
|
print("Verschlüsselungs-Key — zwei Wege:")
|
||||||
|
print(" [1] Aus Benutzername + Passwort ableiten (wie diora's 'Unlock'-Formular im Browser)")
|
||||||
|
print(" [2] Base64-Key direkt einfügen (z.B. per Browser-Konsole exportiert)")
|
||||||
|
choice = input("Wahl [1/2, Standard 1]: ").strip() or "1"
|
||||||
|
if choice == "2":
|
||||||
|
return _prompt("Verschlüsselungs-Key (Base64)", secret=True)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Hinweis: das liefert nur dann den richtigen Key, wenn dieser Account je über "
|
||||||
|
"diora's Passwort-Ableitung ('Unlock with password' im Browser) entsperrt wurde. "
|
||||||
|
"War der Key dort nur automatisch zufällig erzeugt, kommt hier ein anderer "
|
||||||
|
"(falscher) Key raus — 'sync' meldet das dann als Entschlüsselungsfehler, ohne "
|
||||||
|
"etwas kaputtzumachen; in dem Fall stattdessen [2] mit dem exportierten Key nutzen."
|
||||||
|
)
|
||||||
|
username = _prompt("diora-Benutzername")
|
||||||
|
password = _prompt("diora-Passwort", secret=True)
|
||||||
|
return crypto.derive_key_b64(username, password)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig:
|
||||||
|
cfg = None if server_override else config_mod.load()
|
||||||
|
if cfg is not None:
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.")
|
||||||
|
server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/")
|
||||||
|
token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True)
|
||||||
|
enc_key = _prompt_enc_key()
|
||||||
|
cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key)
|
||||||
|
if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"):
|
||||||
|
config_mod.save(cfg)
|
||||||
|
print("Gespeichert.")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None:
|
||||||
|
cfg = _resolve_remote_config(server_override, save=save)
|
||||||
|
print(f"Verbinde zu {cfg.server_url} …")
|
||||||
|
try:
|
||||||
|
result = remote.sync_library(library_dir, cfg)
|
||||||
|
except remote.SyncError as e:
|
||||||
|
raise SystemExit(f"Fehler: {e}")
|
||||||
|
|
||||||
|
print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.")
|
||||||
|
for name in result.downloaded:
|
||||||
|
print(f" + {name}")
|
||||||
|
if result.unchanged:
|
||||||
|
print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.")
|
||||||
|
if result.skipped_pdf:
|
||||||
|
titles = ", ".join(result.skipped_pdf)
|
||||||
|
print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}")
|
||||||
|
if result.failed:
|
||||||
|
titles = ", ".join(result.failed)
|
||||||
|
print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}")
|
||||||
|
if result.progress_pulled:
|
||||||
|
print(f"Lesefortschritt für {result.progress_pulled} Buch/Bücher vom Server übernommen.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--library",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_LIBRARY,
|
||||||
|
help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln")
|
||||||
|
sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf")
|
||||||
|
sync_parser.add_argument(
|
||||||
|
"--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
library_dir = args.library.expanduser()
|
||||||
|
|
||||||
|
if args.command == "sync":
|
||||||
|
run_sync(library_dir, args.server, save=args.save)
|
||||||
|
return
|
||||||
|
|
||||||
|
DioraTuiApp(library_dir).run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
49
tui/diora_tui/app.tcss
Normal file
49
tui/diora_tui/app.tcss
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#library-list {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusBar {
|
||||||
|
dock: bottom;
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
background: $panel;
|
||||||
|
color: $text-muted;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reader-scroll {
|
||||||
|
height: 1fr;
|
||||||
|
padding: 1 2;
|
||||||
|
align-horizontal: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reader-loading {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
FootnoteScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footnote-body {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 100;
|
||||||
|
height: auto;
|
||||||
|
max-height: 80%;
|
||||||
|
background: $panel;
|
||||||
|
border: round $primary;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote-note {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footnote-hint {
|
||||||
|
dock: bottom;
|
||||||
|
width: 80%;
|
||||||
|
max-width: 100;
|
||||||
|
background: $panel;
|
||||||
|
color: $text-muted;
|
||||||
|
padding: 0 2;
|
||||||
|
}
|
||||||
214
tui/diora_tui/blocks.py
Normal file
214
tui/diora_tui/blocks.py
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
"""Whole-book flat block extraction, matching static/js/app.js's EPUB_BLOCK_SELECTOR
|
||||||
|
and getPositionAnchor()/restoreFromAnchor() exactly in *structure* (blockIndex is
|
||||||
|
purely DOM order, no rendering needed) so position anchors are comparable across
|
||||||
|
the web reader and this TUI. See CLAUDE.md's `tui/` section for the full picture.
|
||||||
|
|
||||||
|
innerFraction on the web is defined via getBoundingClientRect() pixel geometry,
|
||||||
|
which has no TUI equivalent — a terminal has no font metrics/reflow the way a
|
||||||
|
browser does. We approximate it with row-based geometry from Rich's own text
|
||||||
|
wrapping (see reader.py), which is close enough because the server's "furthest
|
||||||
|
wins" comparison (_progress_is_further) only falls back to comparing fractions
|
||||||
|
when two anchors share the exact same block index — block index is the primary,
|
||||||
|
exactly-reproducible signal.
|
||||||
|
|
||||||
|
Footnote references are detected with the same heuristic as app.js's
|
||||||
|
_looksLikeFootnoteLink: wrapped in/wrapping a <sup>, a class name containing
|
||||||
|
note/footnote/fn, or an epub:type="noteref" attribute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import warnings
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ebooklib
|
||||||
|
from bs4 import BeautifulSoup, NavigableString, Tag, XMLParsedAsHTMLWarning
|
||||||
|
from ebooklib import epub
|
||||||
|
|
||||||
|
# EPUB content documents are XHTML; treating them as HTML (matching app.js's
|
||||||
|
# `new DOMParser().parseFromString(html, 'text/html')`, static/js/app.js:2638)
|
||||||
|
# is intentional, not a mistake — silence bs4's XML-vs-HTML nudge for it.
|
||||||
|
# lxml's HTML parser is ~3x faster than bs4's built-in html.parser, which
|
||||||
|
# matters here: some real-world EPUBs run to tens of thousands of blocks.
|
||||||
|
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||||
|
_PARSER = "lxml"
|
||||||
|
|
||||||
|
# Matches app.js's EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li,
|
||||||
|
# blockquote, dt, dd, figcaption, div:not(:has(*))'
|
||||||
|
_BLOCK_TAGS = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "dt", "dd", "figcaption"}
|
||||||
|
# Matches app.js's sanitizeEpubHtml() strip list (script/style are also stripped
|
||||||
|
# via regex before DOMParser even runs there; decomposing here is equivalent).
|
||||||
|
_STRIP_TAGS = ["script", "style", "iframe", "object", "embed", "head", "meta", "link"]
|
||||||
|
# Matches app.js's _looksLikeFootnoteLink's class-name check.
|
||||||
|
_FOOTNOTE_CLASS_RE = re.compile(r"\bnote|\bfootnote|\bfn\b")
|
||||||
|
|
||||||
|
# Anchor format the server accepts (books/views.py:save_progress); anything else
|
||||||
|
# is silently discarded back to ''.
|
||||||
|
_ANCHOR_RE = re.compile(r"\d{1,7}:\d(\.\d{1,6})?")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FootnoteRef:
|
||||||
|
marker: str # visible text of the reference link, e.g. "1"
|
||||||
|
offset: int # character offset into the block's text where the marker sits
|
||||||
|
target_id: str # fragment id to resolve against block ids
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Block:
|
||||||
|
text: str
|
||||||
|
tag: str
|
||||||
|
chapter_index: int
|
||||||
|
id: str | None = None
|
||||||
|
footnotes: list[FootnoteRef] = field(default_factory=list)
|
||||||
|
# Every id found anywhere in this block's subtree, not just on the block
|
||||||
|
# element itself — footnote *targets* are frequently an <a id="..."> or
|
||||||
|
# similar nested a level or two inside the actual containing paragraph
|
||||||
|
# (see showFootnotePopover's `.closest('.footnote') || .parentElement`
|
||||||
|
# walk-up in app.js), so a target id resolves to "the block containing
|
||||||
|
# it" rather than requiring the id to sit on the block tag itself.
|
||||||
|
ids: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlatBook:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
author: str
|
||||||
|
path: Path
|
||||||
|
blocks: list[Block]
|
||||||
|
chapter_titles: list[str]
|
||||||
|
chapter_start_block: list[int] # blocks[chapter_start_block[i]] is chapter i's first block
|
||||||
|
footnote_targets: dict[str, int] # fragment id -> block index of its content
|
||||||
|
|
||||||
|
|
||||||
|
def _is_leaf_div(tag: Tag) -> bool:
|
||||||
|
return tag.name == "div" and tag.find(True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_footnote_link(el: Tag, href: str) -> bool:
|
||||||
|
if "#" not in href:
|
||||||
|
return False
|
||||||
|
cls = " ".join(el.get("class") or []).lower()
|
||||||
|
if _FOOTNOTE_CLASS_RE.search(cls):
|
||||||
|
return True
|
||||||
|
epub_type = (el.get("epub:type") or "").lower()
|
||||||
|
if "noteref" in epub_type:
|
||||||
|
return True
|
||||||
|
return el.find_parent("sup") is not None or el.find("sup") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_text(el: Tag, footnotes: list[FootnoteRef], out: list[str]) -> None:
|
||||||
|
for child in el.children:
|
||||||
|
if isinstance(child, NavigableString):
|
||||||
|
out.append(str(child))
|
||||||
|
elif isinstance(child, Tag):
|
||||||
|
if child.name == "a":
|
||||||
|
href = child.get("href") or ""
|
||||||
|
if _looks_like_footnote_link(child, href):
|
||||||
|
marker = child.get_text(" ", strip=True)
|
||||||
|
offset = len("".join(out))
|
||||||
|
target_id = href.split("#", 1)[1] if "#" in href else ""
|
||||||
|
footnotes.append(FootnoteRef(marker=marker, offset=offset, target_id=target_id))
|
||||||
|
out.append(marker)
|
||||||
|
continue
|
||||||
|
_walk_text(child, footnotes, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_blocks(html: bytes, chapter_index: int) -> list[Block]:
|
||||||
|
soup = BeautifulSoup(html, _PARSER)
|
||||||
|
for name in _STRIP_TAGS:
|
||||||
|
for el in soup.find_all(name):
|
||||||
|
el.decompose()
|
||||||
|
|
||||||
|
blocks: list[Block] = []
|
||||||
|
for el in soup.find_all(True):
|
||||||
|
if el.name in _BLOCK_TAGS or _is_leaf_div(el):
|
||||||
|
footnotes: list[FootnoteRef] = []
|
||||||
|
parts: list[str] = []
|
||||||
|
_walk_text(el, footnotes, parts)
|
||||||
|
text = re.sub(r"\s+", " ", "".join(parts)).strip()
|
||||||
|
ids = [tag_id for tag_id in (el.get("id"), *(d.get("id") for d in el.find_all(True))) if tag_id]
|
||||||
|
blocks.append(
|
||||||
|
Block(
|
||||||
|
text=text,
|
||||||
|
tag=el.name,
|
||||||
|
chapter_index=chapter_index,
|
||||||
|
id=el.get("id"),
|
||||||
|
footnotes=footnotes,
|
||||||
|
ids=ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def load_flat_book(path: Path) -> FlatBook:
|
||||||
|
from .epub import _book_id # reuse the same path-hash id scheme
|
||||||
|
|
||||||
|
raw = epub.read_epub(str(path), options={"ignore_ncx": True})
|
||||||
|
|
||||||
|
title_meta = raw.get_metadata("DC", "title")
|
||||||
|
title = title_meta[0][0] if title_meta else path.stem
|
||||||
|
author_meta = raw.get_metadata("DC", "creator")
|
||||||
|
author = author_meta[0][0] if author_meta else "Unbekannt"
|
||||||
|
|
||||||
|
blocks: list[Block] = []
|
||||||
|
chapter_titles: list[str] = []
|
||||||
|
chapter_start_block: list[int] = []
|
||||||
|
|
||||||
|
# app.js's parseEpub() includes every spine itemref unconditionally — no
|
||||||
|
# `linear` filtering (static/js/app.js:2623-2625) — since footnote/endnote
|
||||||
|
# targets are commonly parked in a linear="no" document. Skipping it here
|
||||||
|
# would both break footnote-target resolution and shift blockIndex
|
||||||
|
# numbering out of sync with the web reader for every block after it.
|
||||||
|
for idref, _linear in raw.spine:
|
||||||
|
item = raw.get_item_with_id(idref)
|
||||||
|
if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
||||||
|
continue
|
||||||
|
chapter_index = len(chapter_titles)
|
||||||
|
chapter_blocks = _extract_blocks(item.get_content(), chapter_index)
|
||||||
|
chapter_start_block.append(len(blocks))
|
||||||
|
first_text = next((b.text for b in chapter_blocks if b.text), None)
|
||||||
|
chapter_titles.append((first_text or item.get_name())[:60])
|
||||||
|
blocks.extend(chapter_blocks)
|
||||||
|
|
||||||
|
footnote_targets: dict[str, int] = {}
|
||||||
|
for idx, b in enumerate(blocks):
|
||||||
|
for tag_id in b.ids:
|
||||||
|
footnote_targets.setdefault(tag_id, idx)
|
||||||
|
|
||||||
|
return FlatBook(
|
||||||
|
id=_book_id(path),
|
||||||
|
title=title,
|
||||||
|
author=author,
|
||||||
|
path=path,
|
||||||
|
blocks=blocks,
|
||||||
|
chapter_titles=chapter_titles,
|
||||||
|
chapter_start_block=chapter_start_block,
|
||||||
|
footnote_targets=footnote_targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_anchor(block_index: int, inner_fraction: float) -> str:
|
||||||
|
inner_fraction = max(0.0, min(1.0, inner_fraction))
|
||||||
|
return f"{block_index}:{inner_fraction:.6f}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_anchor(anchor: str) -> tuple[int, float] | None:
|
||||||
|
if not anchor or not _ANCHOR_RE.fullmatch(anchor):
|
||||||
|
return None
|
||||||
|
block_str, _, frac_str = anchor.partition(":")
|
||||||
|
return int(block_str), float(frac_str)
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_is_further(new_anchor: str, old_anchor: str) -> bool:
|
||||||
|
"""Mirrors _progress_is_further / _cmpProgress (books/views.py, app.js)."""
|
||||||
|
new_parts = parse_anchor(new_anchor)
|
||||||
|
old_parts = parse_anchor(old_anchor)
|
||||||
|
if new_parts is None or old_parts is None:
|
||||||
|
return bool(new_anchor) and not old_anchor
|
||||||
|
nb, ni = new_parts
|
||||||
|
ob, oi = old_parts
|
||||||
|
return ni >= oi if nb == ob else nb >= ob
|
||||||
42
tui/diora_tui/book_view.py
Normal file
42
tui/diora_tui/book_view.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Line-API scroll view for the continuous reader — renders only the rows
|
||||||
|
actually visible on screen (via Widget.render_line), reading from the flat
|
||||||
|
Strip list diora_tui.layout.build_layout() precomputed once. This is what
|
||||||
|
keeps very large books responsive: nothing here scales with book size at
|
||||||
|
paint time, only with viewport height.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.geometry import Size
|
||||||
|
from textual.scroll_view import ScrollView
|
||||||
|
from textual.strip import Strip
|
||||||
|
|
||||||
|
from .layout import BookLayout
|
||||||
|
|
||||||
|
|
||||||
|
class ContinuousBookView(ScrollView):
|
||||||
|
# We always wrap text to fit exactly, so a horizontal scrollbar should
|
||||||
|
# never be needed — and "scroll" (not "auto") for the vertical one keeps
|
||||||
|
# its gutter reserved from the very first (still-empty) layout pass, so
|
||||||
|
# the width we wrap text at later never has to guess whether a scrollbar
|
||||||
|
# will appear and steal columns out from under already-wrapped lines.
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
ContinuousBookView {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, book_layout: BookLayout) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.book_layout = book_layout
|
||||||
|
self.virtual_size = Size(book_layout.width, book_layout.total_rows)
|
||||||
|
|
||||||
|
def render_line(self, y: int) -> Strip:
|
||||||
|
_scroll_x, scroll_y = self.scroll_offset
|
||||||
|
row = scroll_y + y
|
||||||
|
strips = self.book_layout.row_strips
|
||||||
|
width = self.scrollable_content_region.width
|
||||||
|
if row < 0 or row >= len(strips):
|
||||||
|
return Strip.blank(width, self.rich_style)
|
||||||
|
return strips[row].crop_extend(0, width, self.rich_style)
|
||||||
52
tui/diora_tui/cache.py
Normal file
52
tui/diora_tui/cache.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""On-disk cache for the (FlatBook, BookLayout) pair a book open computes —
|
||||||
|
extraction + row-layout for very large books (tens of thousands of blocks)
|
||||||
|
can take several seconds each; reopening the same book at the same terminal
|
||||||
|
width should be near-instant instead of paying that cost again every time.
|
||||||
|
|
||||||
|
Not a correctness-critical cache: any miss (new book, different width, edited
|
||||||
|
file) just falls back to recomputing from scratch, so a stale/corrupt cache
|
||||||
|
entry is handled by overwriting it, never by crashing the reader.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_cache_dir
|
||||||
|
|
||||||
|
from .blocks import FlatBook
|
||||||
|
from .layout import BookLayout
|
||||||
|
|
||||||
|
_CACHE_DIR = Path(user_cache_dir("diora-tui", "diora")) / "layout_cache"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(path: Path, width: int) -> str:
|
||||||
|
stat = path.stat()
|
||||||
|
raw = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}|{width}"
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path, width: int) -> tuple[FlatBook, BookLayout] | None:
|
||||||
|
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
|
||||||
|
if not cache_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with cache_file.open("rb") as f:
|
||||||
|
book, book_layout = pickle.load(f)
|
||||||
|
if not isinstance(book, FlatBook) or not isinstance(book_layout, BookLayout):
|
||||||
|
return None
|
||||||
|
return book, book_layout
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save(path: Path, width: int, book: FlatBook, book_layout: BookLayout) -> None:
|
||||||
|
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_file = _CACHE_DIR / f"{_cache_key(path, width)}.pickle"
|
||||||
|
try:
|
||||||
|
with cache_file.open("wb") as f:
|
||||||
|
pickle.dump((book, book_layout), f, protocol=pickle.HIGHEST_PROTOCOL)
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort — a failed cache write shouldn't break reading
|
||||||
42
tui/diora_tui/config.py
Normal file
42
tui/diora_tui/config.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Local storage for diora server credentials used by `diora-tui sync`.
|
||||||
|
|
||||||
|
Mirrors the trust model of the web client's key storage (static/js/app.js,
|
||||||
|
getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept
|
||||||
|
in plaintext on disk, scoped to this machine/user (0600), with no additional
|
||||||
|
at-rest encryption — same exposure as the browser's localStorage already has.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
|
_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora"))
|
||||||
|
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RemoteConfig:
|
||||||
|
server_url: str
|
||||||
|
api_token: str
|
||||||
|
enc_key_b64: str
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> RemoteConfig | None:
|
||||||
|
if not _CONFIG_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(_CONFIG_FILE.read_text())
|
||||||
|
return RemoteConfig(**data)
|
||||||
|
except (json.JSONDecodeError, OSError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save(cfg: RemoteConfig) -> None:
|
||||||
|
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2))
|
||||||
|
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||||
51
tui/diora_tui/crypto.py
Normal file
51
tui/diora_tui/crypto.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes
|
||||||
|
(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled
|
||||||
|
as raw bytes) — diora's books are end-to-end encrypted client-side, so the
|
||||||
|
server (and this module) only ever sees ciphertext plus the key the user
|
||||||
|
supplies out-of-band.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
_PBKDF2_ITERATIONS = 200_000
|
||||||
|
_KEY_LENGTH_BYTES = 32
|
||||||
|
|
||||||
|
|
||||||
|
class DecryptError(Exception):
|
||||||
|
"""Ciphertext could not be decrypted with the given key (wrong key, or corrupt data)."""
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(key_b64)
|
||||||
|
iv = bytes.fromhex(iv_hex)
|
||||||
|
ct = base64.b64decode(ciphertext_b64)
|
||||||
|
return AESGCM(key).decrypt(iv, ct, None)
|
||||||
|
except (InvalidTag, ValueError) as e:
|
||||||
|
raise DecryptError(str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
def derive_key_b64(username: str, password: str) -> str:
|
||||||
|
"""Re-derive the AES-256 key the same way app.js's deriveAndStoreKey() does:
|
||||||
|
PBKDF2-HMAC-SHA256, 200000 iterations, salt = "diora:" + username. Only
|
||||||
|
yields the key that actually decrypts a user's books if that account's
|
||||||
|
key was ever set up via diora's "unlock with password" flow — a browser
|
||||||
|
that only ever auto-generated a random key (the default) has a key this
|
||||||
|
can't reproduce.
|
||||||
|
"""
|
||||||
|
salt = f"diora:{username}".encode("utf-8")
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=_KEY_LENGTH_BYTES,
|
||||||
|
salt=salt,
|
||||||
|
iterations=_PBKDF2_ITERATIONS,
|
||||||
|
)
|
||||||
|
raw = kdf.derive(password.encode("utf-8"))
|
||||||
|
return base64.b64encode(raw).decode()
|
||||||
19
tui/diora_tui/epub.py
Normal file
19
tui/diora_tui/epub.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Library scanning and the stable per-book id shared across progress.py,
|
||||||
|
blocks.py, and remote.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _book_id(path: Path) -> str:
|
||||||
|
# Identifies a book by its resolved path for now. Once the sync API exists,
|
||||||
|
# this should switch to whatever stable id diora assigns server-side.
|
||||||
|
return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_library(library_dir: Path) -> list[Path]:
|
||||||
|
if not library_dir.exists():
|
||||||
|
return []
|
||||||
|
return sorted(library_dir.rglob("*.epub"))
|
||||||
90
tui/diora_tui/layout.py
Normal file
90
tui/diora_tui/layout.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Row-based layout for the continuous reader view — the TUI's terminal-grid
|
||||||
|
analogue of the browser's pixel-based getBoundingClientRect() geometry (see
|
||||||
|
blocks.py's module docstring for why blockIndex is exact but innerFraction is
|
||||||
|
only an approximation here).
|
||||||
|
|
||||||
|
Each block is wrapped independently at a known width, once, into a flat list
|
||||||
|
of pre-rendered Strip objects (one per terminal row) that book_view.py's
|
||||||
|
Line-API widget indexes directly in render_line() — this is what makes very
|
||||||
|
large books (tens of thousands of blocks) open in seconds rather than
|
||||||
|
minutes: Textual only ever renders the rows actually on screen, instead of
|
||||||
|
laying out the whole book up front the way a single giant Static would.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.strip import Strip
|
||||||
|
|
||||||
|
_SEPARATOR_ROWS = 1 # one blank row between blocks, matching the old "\n\n".join style
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BookLayout:
|
||||||
|
starts: list[int] # row where block i begins
|
||||||
|
heights: list[int] # rendered row count for block i
|
||||||
|
row_strips: list[Strip] # one Strip per absolute row, len == total_rows
|
||||||
|
total_rows: int
|
||||||
|
width: int
|
||||||
|
|
||||||
|
|
||||||
|
def build_layout(block_texts: list[str], width: int, console: Console) -> BookLayout:
|
||||||
|
width = max(1, width)
|
||||||
|
starts: list[int] = []
|
||||||
|
heights: list[int] = []
|
||||||
|
row_strips: list[Strip] = []
|
||||||
|
blank = Strip.blank(width)
|
||||||
|
row = 0
|
||||||
|
for text in block_texts:
|
||||||
|
starts.append(row)
|
||||||
|
wrapped_lines = list(Text(text).wrap(console, width)) if text else []
|
||||||
|
if not wrapped_lines:
|
||||||
|
wrapped_lines = [Text("")]
|
||||||
|
heights.append(len(wrapped_lines))
|
||||||
|
for line in wrapped_lines:
|
||||||
|
strip = Strip(line.render(console), None).adjust_cell_length(width)
|
||||||
|
row_strips.append(strip)
|
||||||
|
row_strips.append(blank)
|
||||||
|
row += len(wrapped_lines) + _SEPARATOR_ROWS
|
||||||
|
|
||||||
|
return BookLayout(starts=starts, heights=heights, row_strips=row_strips, total_rows=row, width=width)
|
||||||
|
|
||||||
|
|
||||||
|
def get_position_anchor(layout: BookLayout, scroll_y: int) -> tuple[int, float]:
|
||||||
|
"""Mirrors app.js's getPositionAnchor(): the last block whose top row is at
|
||||||
|
or above scroll_y, or the first block if none has scrolled that far yet."""
|
||||||
|
n = len(layout.heights)
|
||||||
|
if n == 0:
|
||||||
|
return 0, 0.0
|
||||||
|
|
||||||
|
best_index = 0
|
||||||
|
found = False
|
||||||
|
for i in range(n):
|
||||||
|
if layout.heights[i] < 1:
|
||||||
|
continue
|
||||||
|
if layout.starts[i] > scroll_y:
|
||||||
|
break
|
||||||
|
best_index = i
|
||||||
|
found = True
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
best_index = next((i for i in range(n) if layout.heights[i] >= 1), 0)
|
||||||
|
|
||||||
|
top = layout.starts[best_index]
|
||||||
|
height = max(1, layout.heights[best_index])
|
||||||
|
inner_fraction = max(0.0, min(1.0, (scroll_y - top) / height))
|
||||||
|
return best_index, inner_fraction
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_y_for_anchor(layout: BookLayout, block_index: int, inner_fraction: float) -> int:
|
||||||
|
"""Mirrors app.js's restoreFromAnchor()."""
|
||||||
|
n = len(layout.heights)
|
||||||
|
if n == 0:
|
||||||
|
return 0
|
||||||
|
idx = max(0, min(block_index, n - 1))
|
||||||
|
top = layout.starts[idx]
|
||||||
|
height = layout.heights[idx]
|
||||||
|
return top + round(inner_fraction * height)
|
||||||
43
tui/diora_tui/library_meta.py
Normal file
43
tui/diora_tui/library_meta.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Local per-book metadata pulled from the server — currently just "read"
|
||||||
|
status (EBook.is_read from the /api/sync/ snapshot). Kept separate from
|
||||||
|
progress.py (reading position): different concern, different cadence — this
|
||||||
|
is only ever set by `diora-tui sync`, never by the reader itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_data_dir
|
||||||
|
|
||||||
|
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||||
|
_META_FILE = _DATA_DIR / "library.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all() -> dict[str, dict]:
|
||||||
|
if not _META_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(_META_FILE.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all(data: dict[str, dict]) -> None:
|
||||||
|
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_META_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def is_read(book_id: str) -> bool:
|
||||||
|
entry = _load_all().get(book_id)
|
||||||
|
return bool(entry and entry.get("is_read"))
|
||||||
|
|
||||||
|
|
||||||
|
def set_many(read_status: dict[str, bool]) -> None:
|
||||||
|
if not read_status:
|
||||||
|
return
|
||||||
|
data = _load_all()
|
||||||
|
for book_id, read in read_status.items():
|
||||||
|
data.setdefault(book_id, {})["is_read"] = read
|
||||||
|
_save_all(data)
|
||||||
68
tui/diora_tui/progress.py
Normal file
68
tui/diora_tui/progress.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Local reading-progress storage.
|
||||||
|
|
||||||
|
Positions are stored as the same "blockIndex:innerFraction" anchor string the
|
||||||
|
server uses (books/models.py, EBookProgress.save_progress) — see
|
||||||
|
diora_tui/blocks.py for how blockIndex is computed to match static/js/app.js
|
||||||
|
exactly, and anchor_is_further() for the identical "furthest wins" comparison
|
||||||
|
used server-side (_progress_is_further in books/views.py). A saved position
|
||||||
|
only ever advances (unless forced), so an older/offline run can't regress a
|
||||||
|
further-along read position — matching that same rule locally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from platformdirs import user_data_dir
|
||||||
|
|
||||||
|
from .blocks import anchor_is_further
|
||||||
|
|
||||||
|
_DATA_DIR = Path(user_data_dir("diora-tui", "diora"))
|
||||||
|
_PROGRESS_FILE = _DATA_DIR / "progress.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
anchor: str # "blockIndex:innerFraction", e.g. "42:0.500000"
|
||||||
|
updated_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all() -> dict[str, dict]:
|
||||||
|
if not _PROGRESS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(_PROGRESS_FILE.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all(data: dict[str, dict]) -> None:
|
||||||
|
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_PROGRESS_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def load(book_id: str) -> Position | None:
|
||||||
|
entry = _load_all().get(book_id)
|
||||||
|
if not isinstance(entry, dict) or not entry.get("anchor"):
|
||||||
|
return None
|
||||||
|
return Position(anchor=entry["anchor"], updated_at=entry.get("updated_at", 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
def save(book_id: str, position: Position, *, force: bool = False) -> bool:
|
||||||
|
"""Returns True if the position was actually written (i.e. it was further
|
||||||
|
along, or forced) — callers that push to the server only need to do so
|
||||||
|
when this returns True."""
|
||||||
|
data = _load_all()
|
||||||
|
existing = data.get(book_id)
|
||||||
|
if existing is not None and not force:
|
||||||
|
old_anchor = existing.get("anchor", "")
|
||||||
|
if not anchor_is_further(position.anchor, old_anchor):
|
||||||
|
return False
|
||||||
|
if not position.updated_at:
|
||||||
|
position.updated_at = time.time()
|
||||||
|
data[book_id] = {"anchor": position.anchor, "updated_at": position.updated_at}
|
||||||
|
_save_all(data)
|
||||||
|
return True
|
||||||
286
tui/diora_tui/reader_screen.py
Normal file
286
tui/diora_tui/reader_screen.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
"""Continuous whole-book reader screen — mirrors the web reader's single
|
||||||
|
scrollable view (see blocks.py's module docstring) instead of the old
|
||||||
|
per-chapter pagination, so reading position is expressed in the same
|
||||||
|
"blockIndex:innerFraction" anchor space as the server and the web client.
|
||||||
|
|
||||||
|
Uses book_view.ContinuousBookView (a Line-API widget) rather than dumping the
|
||||||
|
whole book into one Static: for large books (tens of thousands of blocks) a
|
||||||
|
single giant renderable made Textual's layout/paint pass take upwards of a
|
||||||
|
minute, whereas the Line API only ever renders the rows on screen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from textual import work
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Container, VerticalScroll
|
||||||
|
from textual.geometry import Size
|
||||||
|
from textual.screen import ModalScreen, Screen
|
||||||
|
from textual.widgets import Footer, Header, Label, LoadingIndicator, Static
|
||||||
|
|
||||||
|
from . import api, blocks, cache, config as config_mod, layout, progress
|
||||||
|
from .book_view import ContinuousBookView
|
||||||
|
from .statusbar import StatusBar
|
||||||
|
|
||||||
|
MAX_LINE_WIDTH = 120
|
||||||
|
AUTOSAVE_INTERVAL = 5.0
|
||||||
|
# ContinuousBookView forces its vertical scrollbar always-on (see book_view.py)
|
||||||
|
# so this stays constant — measuring the container's width and subtracting
|
||||||
|
# this fixed amount avoids a measure-after-constrain race against book_view's
|
||||||
|
# own (possibly already-constrained-from-a-previous-layout) width.
|
||||||
|
SCROLLBAR_GUTTER = 2
|
||||||
|
|
||||||
|
_EMPTY_LAYOUT = layout.BookLayout(starts=[], heights=[], row_strips=[], total_rows=0, width=1)
|
||||||
|
|
||||||
|
# Downloaded-via-sync files are named "<server-id> - <title>.epub" (see
|
||||||
|
# remote.py) — reused here to find the server book id for progress push.
|
||||||
|
_SERVER_ID_RE = re.compile(r"^(\d{4,7}) - ")
|
||||||
|
|
||||||
|
|
||||||
|
def _server_book_id(path: Path) -> int | None:
|
||||||
|
m = _SERVER_ID_RE.match(path.name)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
class FootnoteScreen(ModalScreen[None]):
|
||||||
|
BINDINGS = [Binding("escape,f,q", "dismiss_self", "Schließen")]
|
||||||
|
|
||||||
|
def __init__(self, notes: list[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._notes = notes
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
with VerticalScroll(id="footnote-body"):
|
||||||
|
for note in self._notes:
|
||||||
|
yield Static(note, classes="footnote-note")
|
||||||
|
yield Label("Escape/f zum Schließen", id="footnote-hint")
|
||||||
|
|
||||||
|
def action_dismiss_self(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderScreen(Screen):
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("j,down", "scroll_down_line", "Runter", show=False),
|
||||||
|
Binding("k,up", "scroll_up_line", "Hoch", show=False),
|
||||||
|
Binding("n", "next_chapter", "Nächstes Kapitel"),
|
||||||
|
Binding("p", "prev_chapter", "Vorheriges Kapitel"),
|
||||||
|
Binding("f", "peek_footnote", "Fußnote"),
|
||||||
|
Binding("q,escape", "back", "Zurück zur Bibliothek"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.book: blocks.FlatBook | None = None
|
||||||
|
self.book_layout: layout.BookLayout = _EMPTY_LAYOUT
|
||||||
|
self._server_id = _server_book_id(path)
|
||||||
|
self._remote_cfg = config_mod.load() if self._server_id is not None else None
|
||||||
|
self._last_pushed_anchor = ""
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
yield Header()
|
||||||
|
yield LoadingIndicator(id="reader-loading")
|
||||||
|
with Container(id="reader-scroll") as scroll_container:
|
||||||
|
self._scroll_container = scroll_container
|
||||||
|
# Stays visible (empty) from the start rather than toggling display
|
||||||
|
# on once loaded — a widget that's just been switched from hidden
|
||||||
|
# to visible hasn't been through a layout pass yet, so its `.size`
|
||||||
|
# is still (0, 0) and an immediate scroll_to() right after has
|
||||||
|
# nothing to clamp against and silently resets to 0.
|
||||||
|
self._book_view = ContinuousBookView(_EMPTY_LAYOUT)
|
||||||
|
yield self._book_view
|
||||||
|
yield StatusBar()
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
# Deferred rather than read synchronously here: right after mount the
|
||||||
|
# container hasn't been through a layout pass yet, so its size isn't
|
||||||
|
# reliable — same lesson as the scroll-restore race below.
|
||||||
|
self.call_after_refresh(self._start_loading)
|
||||||
|
|
||||||
|
def _start_loading(self) -> None:
|
||||||
|
if self._scroll_container.size.width == 0:
|
||||||
|
# Not sized yet after all — keep deferring instead of guessing a
|
||||||
|
# fixed number of refresh cycles (mirrors _restore_position below).
|
||||||
|
self.call_after_refresh(self._start_loading)
|
||||||
|
return
|
||||||
|
self._load_book(self._content_width())
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _load_book(self, width: int) -> None:
|
||||||
|
cached = cache.load(self.path, width)
|
||||||
|
if cached is not None:
|
||||||
|
book, book_layout = cached
|
||||||
|
else:
|
||||||
|
book = blocks.load_flat_book(self.path)
|
||||||
|
console = Console(width=width)
|
||||||
|
book_layout = layout.build_layout([b.text for b in book.blocks], width, console)
|
||||||
|
cache.save(self.path, width, book, book_layout)
|
||||||
|
self.app.call_from_thread(self._on_book_loaded, book, book_layout, width)
|
||||||
|
|
||||||
|
def _on_book_loaded(self, book: blocks.FlatBook, book_layout: layout.BookLayout, width: int) -> None:
|
||||||
|
self.book = book
|
||||||
|
self.sub_title = book.title
|
||||||
|
self._apply_layout(book_layout, width)
|
||||||
|
self.query_one("#reader-loading", LoadingIndicator).display = False
|
||||||
|
self.call_after_refresh(self._restore_position)
|
||||||
|
self.set_interval(AUTOSAVE_INTERVAL, self._autosave)
|
||||||
|
|
||||||
|
def _content_width(self) -> int:
|
||||||
|
# Measuring the *container* rather than book_view's own size avoids a
|
||||||
|
# measure-after-constrain race: once a layout has been applied,
|
||||||
|
# book_view's width is pinned to a previous value via styles.width
|
||||||
|
# (below), so re-measuring book_view itself on a later resize would
|
||||||
|
# just read that stale pinned width back instead of the new
|
||||||
|
# available space. The container's width is unaffected by that.
|
||||||
|
available = self._scroll_container.size.width - SCROLLBAR_GUTTER
|
||||||
|
return max(20, min(MAX_LINE_WIDTH, available))
|
||||||
|
|
||||||
|
def _apply_layout(self, book_layout: layout.BookLayout, width: int) -> None:
|
||||||
|
self.book_layout = book_layout
|
||||||
|
# Pin book_view's outer width to content-width-plus-scrollbar so its
|
||||||
|
# *inner* content region (what render_line actually draws into) ends
|
||||||
|
# up exactly `width` — matching what block texts were wrapped at.
|
||||||
|
self._book_view.styles.width = width + SCROLLBAR_GUTTER
|
||||||
|
self._book_view.book_layout = book_layout
|
||||||
|
self._book_view.virtual_size = Size(width, book_layout.total_rows)
|
||||||
|
self._book_view.refresh()
|
||||||
|
|
||||||
|
def _build_layout(self) -> None:
|
||||||
|
assert self.book is not None
|
||||||
|
width = self._content_width()
|
||||||
|
console = Console(width=width)
|
||||||
|
block_texts = [b.text for b in self.book.blocks]
|
||||||
|
new_layout = layout.build_layout(block_texts, width, console)
|
||||||
|
cache.save(self.path, width, self.book, new_layout)
|
||||||
|
self._apply_layout(new_layout, width)
|
||||||
|
|
||||||
|
def on_resize(self) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
old_y = self._book_view.scroll_y
|
||||||
|
anchor_block, anchor_frac = layout.get_position_anchor(self.book_layout, old_y)
|
||||||
|
self._build_layout()
|
||||||
|
new_y = layout.scroll_y_for_anchor(self.book_layout, anchor_block, anchor_frac)
|
||||||
|
self._book_view.scroll_to(y=new_y, animate=False, immediate=True)
|
||||||
|
|
||||||
|
def _restore_position(self, attempt: int = 0) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
saved = progress.load(self.book.id)
|
||||||
|
if saved is None:
|
||||||
|
return
|
||||||
|
parsed = blocks.parse_anchor(saved.anchor)
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
block_index, inner_fraction = parsed
|
||||||
|
y = layout.scroll_y_for_anchor(self.book_layout, block_index, inner_fraction)
|
||||||
|
if y <= 0:
|
||||||
|
return
|
||||||
|
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||||
|
# A widget that's only just become part of the layout doesn't always
|
||||||
|
# honor an immediate scroll on the first attempt (its own size/scroll
|
||||||
|
# bounds can still be mid-update) — verify it actually landed and
|
||||||
|
# retry a bounded number of times rather than guessing a fixed delay.
|
||||||
|
if attempt < 20 and abs(self._book_view.scroll_y - y) > 1:
|
||||||
|
self.call_after_refresh(lambda: self._restore_position(attempt + 1))
|
||||||
|
|
||||||
|
def _current_anchor_str(self) -> str:
|
||||||
|
block_index, inner_fraction = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||||
|
return blocks.format_anchor(block_index, inner_fraction)
|
||||||
|
|
||||||
|
def _autosave(self) -> None:
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def _save_progress(self) -> None:
|
||||||
|
if self.book is None or not self.book_layout.heights:
|
||||||
|
return
|
||||||
|
anchor = self._current_anchor_str()
|
||||||
|
advanced = progress.save(self.book.id, progress.Position(anchor=anchor))
|
||||||
|
if advanced and self._remote_cfg is not None and self._server_id is not None:
|
||||||
|
self._push_remote_progress(anchor)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="progress-push")
|
||||||
|
def _push_remote_progress(self, anchor: str) -> None:
|
||||||
|
if anchor == self._last_pushed_anchor or self._remote_cfg is None or self._server_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
api.post_progress(
|
||||||
|
self._remote_cfg.server_url,
|
||||||
|
self._remote_cfg.api_token,
|
||||||
|
self._server_id,
|
||||||
|
scroll_fraction=0.0,
|
||||||
|
position_anchor=anchor,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
self._last_pushed_anchor = anchor
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort — local progress is already saved regardless
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def action_scroll_down_line(self) -> None:
|
||||||
|
self._book_view.scroll_relative(y=1, animate=False)
|
||||||
|
|
||||||
|
def action_scroll_up_line(self) -> None:
|
||||||
|
self._book_view.scroll_relative(y=-1, animate=False)
|
||||||
|
|
||||||
|
def _current_chapter_index(self) -> int:
|
||||||
|
if self.book is None:
|
||||||
|
return 0
|
||||||
|
block_index, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||||
|
return bisect.bisect_right(self.book.chapter_start_block, block_index) - 1
|
||||||
|
|
||||||
|
def _jump_to_block(self, block_index: int) -> None:
|
||||||
|
y = layout.scroll_y_for_anchor(self.book_layout, block_index, 0.0)
|
||||||
|
self._book_view.scroll_to(y=y, animate=False, immediate=True)
|
||||||
|
|
||||||
|
def action_next_chapter(self) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
chapter = self._current_chapter_index()
|
||||||
|
if chapter + 1 < len(self.book.chapter_start_block):
|
||||||
|
self._jump_to_block(self.book.chapter_start_block[chapter + 1])
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def action_prev_chapter(self) -> None:
|
||||||
|
if self.book is None:
|
||||||
|
return
|
||||||
|
chapter = self._current_chapter_index()
|
||||||
|
if chapter > 0:
|
||||||
|
self._jump_to_block(self.book.chapter_start_block[chapter - 1])
|
||||||
|
self._save_progress()
|
||||||
|
|
||||||
|
def action_peek_footnote(self) -> None:
|
||||||
|
if self.book is None or not self.book_layout.heights:
|
||||||
|
return
|
||||||
|
current_block, _ = layout.get_position_anchor(self.book_layout, self._book_view.scroll_y)
|
||||||
|
|
||||||
|
forward = [(i, b) for i, b in enumerate(self.book.blocks) if i >= current_block and b.footnotes]
|
||||||
|
backward = [(i, b) for i, b in enumerate(self.book.blocks) if i < current_block and b.footnotes]
|
||||||
|
candidate = forward[0] if forward else (backward[-1] if backward else None)
|
||||||
|
if candidate is None:
|
||||||
|
self.notify("Keine Fußnote in diesem Buch gefunden.", timeout=3)
|
||||||
|
return
|
||||||
|
|
||||||
|
_, block = candidate
|
||||||
|
notes: list[str] = []
|
||||||
|
for ref in block.footnotes:
|
||||||
|
target_idx = self.book.footnote_targets.get(ref.target_id)
|
||||||
|
if target_idx is None:
|
||||||
|
continue
|
||||||
|
notes.append(self.book.blocks[target_idx].text)
|
||||||
|
if not notes:
|
||||||
|
self.notify("Fußnote konnte nicht aufgelöst werden.", timeout=3)
|
||||||
|
return
|
||||||
|
self.app.push_screen(FootnoteScreen(notes))
|
||||||
|
|
||||||
|
def action_back(self) -> None:
|
||||||
|
self.app.pop_screen()
|
||||||
110
tui/diora_tui/remote.py
Normal file
110
tui/diora_tui/remote.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Fetch + decrypt books from a diora server into the local TUI library, and
|
||||||
|
pull reading progress for them into the local progress store.
|
||||||
|
|
||||||
|
Progress is pull-only here (server -> local); the reader pushes local ->
|
||||||
|
server itself while a book is open (see reader_screen.py). Both directions
|
||||||
|
use the same "blockIndex:innerFraction" anchor format as the web reader
|
||||||
|
(diora_tui/blocks.py) and the same furthest-wins merge rule
|
||||||
|
(diora_tui/progress.py, books/views.py's _progress_is_further) — a pull can
|
||||||
|
only ever advance local progress, never regress it, unless forced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import api, crypto, library_meta
|
||||||
|
from .config import RemoteConfig
|
||||||
|
from .epub import _book_id
|
||||||
|
from .progress import Position, save as save_progress
|
||||||
|
|
||||||
|
|
||||||
|
class SyncError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncResult:
|
||||||
|
downloaded: list[str] = field(default_factory=list)
|
||||||
|
unchanged: int = 0
|
||||||
|
skipped_pdf: list[str] = field(default_factory=list)
|
||||||
|
failed: list[str] = field(default_factory=list)
|
||||||
|
progress_pulled: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
name = re.sub(r"[^\w\s.-]", "_", name).strip()
|
||||||
|
return (name or "buch")[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meta(raw: bytes) -> dict:
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult:
|
||||||
|
try:
|
||||||
|
snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token)
|
||||||
|
except api.ApiError as e:
|
||||||
|
raise SyncError(str(e)) from e
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e
|
||||||
|
|
||||||
|
library_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
result = SyncResult()
|
||||||
|
local_path_by_server_id: dict[int, Path] = {}
|
||||||
|
|
||||||
|
for book in snapshot.get("books", []):
|
||||||
|
book_id = book["id"]
|
||||||
|
try:
|
||||||
|
meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"]))
|
||||||
|
except crypto.DecryptError:
|
||||||
|
result.failed.append(f"#{book_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if meta.get("type") == "pdf":
|
||||||
|
result.skipped_pdf.append(meta.get("title") or f"#{book_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None)
|
||||||
|
if existing is not None:
|
||||||
|
result.unchanged += 1
|
||||||
|
local_path_by_server_id[book_id] = existing
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id)
|
||||||
|
raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"])
|
||||||
|
except (api.ApiError, crypto.DecryptError, requests.RequestException):
|
||||||
|
result.failed.append(meta.get("title") or f"#{book_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = meta.get("title") or f"book-{book_id}"
|
||||||
|
dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub"
|
||||||
|
dest.write_bytes(raw)
|
||||||
|
result.downloaded.append(dest.name)
|
||||||
|
local_path_by_server_id[book_id] = dest
|
||||||
|
|
||||||
|
for entry in snapshot.get("book_progress", []):
|
||||||
|
anchor = entry.get("position_anchor") or ""
|
||||||
|
if not anchor:
|
||||||
|
continue # PDF-only scroll_fraction progress — this TUI is EPUB-only
|
||||||
|
dest = local_path_by_server_id.get(entry.get("book_id"))
|
||||||
|
if dest is None:
|
||||||
|
continue
|
||||||
|
local_id = _book_id(dest)
|
||||||
|
if save_progress(local_id, Position(anchor=anchor)):
|
||||||
|
result.progress_pulled += 1
|
||||||
|
|
||||||
|
read_status = {
|
||||||
|
_book_id(path): bool(book.get("is_read"))
|
||||||
|
for book in snapshot.get("books", [])
|
||||||
|
if (path := local_path_by_server_id.get(book["id"])) is not None
|
||||||
|
}
|
||||||
|
library_meta.set_many(read_status)
|
||||||
|
|
||||||
|
return result
|
||||||
44
tui/diora_tui/statusbar.py
Normal file
44
tui/diora_tui/statusbar.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Bottom status bar: battery level + current time, refreshed every second.
|
||||||
|
Battery is read via psutil (cross-platform); on a desktop machine with no
|
||||||
|
battery, psutil.sensors_battery() returns None and the bar just shows time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError:
|
||||||
|
psutil = None # battery display degrades gracefully to time-only
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBar(Static):
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._refresh()
|
||||||
|
self.set_interval(1.0, self._refresh)
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
self.update(self._render_text())
|
||||||
|
|
||||||
|
def _render_text(self) -> str:
|
||||||
|
parts = []
|
||||||
|
battery = self._battery_text()
|
||||||
|
if battery:
|
||||||
|
parts.append(battery)
|
||||||
|
parts.append(datetime.now().strftime("%H:%M:%S"))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
def _battery_text(self) -> str | None:
|
||||||
|
if psutil is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
battery = psutil.sensors_battery()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if battery is None:
|
||||||
|
return None
|
||||||
|
state = "lädt" if battery.power_plugged else "Akku"
|
||||||
|
return f"{state} {round(battery.percent)}%"
|
||||||
25
tui/pyproject.toml
Normal file
25
tui/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[project]
|
||||||
|
name = "diora-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Terminal-EPUB-Reader für diora mit Server-Sync für Bücher und Lesefortschritt."
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"textual>=0.60",
|
||||||
|
"ebooklib>=0.18",
|
||||||
|
"beautifulsoup4>=4.12",
|
||||||
|
"lxml>=5.0",
|
||||||
|
"platformdirs>=4.0",
|
||||||
|
"requests>=2.31",
|
||||||
|
"cryptography>=42.0",
|
||||||
|
"psutil>=5.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
diora-tui = "diora_tui.app:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["diora_tui"]
|
||||||
Loading…
Add table
Reference in a new issue