Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop. - Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht. Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben. /books/ mit X-Server-Now, /api/sync/ mit server_now. - Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert. Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat. Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle". - Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste Stelle, Liste und Snapshot). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fda6c01943
commit
60dd4926cf
9 changed files with 693 additions and 111 deletions
|
|
@ -4,6 +4,7 @@ from django.utils import timezone
|
|||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
|
||||
from books.views import _now_ms, progress_state
|
||||
from podcasts.models import PodcastFeed, EpisodeProgress, PodcastQueue
|
||||
from radio.models import SavedStation
|
||||
|
||||
|
|
@ -33,11 +34,10 @@ def sync_snapshot(request):
|
|||
for b in books:
|
||||
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
||||
|
||||
book_progress = list(
|
||||
EBookProgress.objects.filter(user=user).values('book_id', 'scroll_fraction', 'position_anchor', 'updated_at')
|
||||
)
|
||||
for p in book_progress:
|
||||
p['updated_at'] = p['updated_at'].isoformat()
|
||||
book_progress = [
|
||||
{'book_id': p.book_id, 'updated_at': p.updated_at.isoformat(), **progress_state(p)}
|
||||
for p in EBookProgress.objects.filter(user=user)
|
||||
]
|
||||
|
||||
book_highlights = list(EBookHighlights.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
|
||||
for h in book_highlights:
|
||||
|
|
@ -50,6 +50,7 @@ def sync_snapshot(request):
|
|||
if request.GET.get('only') == 'books':
|
||||
return JsonResponse({
|
||||
'server_time': timezone.now().isoformat(),
|
||||
'server_now': _now_ms(),
|
||||
'books': books,
|
||||
'book_progress': book_progress,
|
||||
'book_highlights': book_highlights,
|
||||
|
|
@ -84,6 +85,7 @@ def sync_snapshot(request):
|
|||
|
||||
return JsonResponse({
|
||||
'server_time': timezone.now().isoformat(),
|
||||
'server_now': _now_ms(),
|
||||
'books': books,
|
||||
'book_progress': book_progress,
|
||||
'book_highlights': book_highlights,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class SyncSnapshotTests(TestCase):
|
|||
def test_only_books(self):
|
||||
resp = self.client.get('/api/sync/?only=books', HTTP_AUTHORIZATION=f'Bearer {self.token.token}')
|
||||
data = resp.json()
|
||||
self.assertEqual(set(data), {'server_time', 'books', 'book_progress', 'book_highlights', 'book_bookmarks'})
|
||||
self.assertEqual(set(data), {'server_time', 'server_now', 'books', 'book_progress', 'book_highlights', 'book_bookmarks'})
|
||||
self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct')
|
||||
|
||||
def test_snapshot_shape_and_data(self):
|
||||
|
|
|
|||
43
books/migrations/0005_progress_last_read_wins.py
Normal file
43
books/migrations/0005_progress_last_read_wins.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
def keep_what_we_know(apps, schema_editor):
|
||||
"""The continue point so far was the furthest place: it is that, and it was
|
||||
last saved at updated_at."""
|
||||
EBookProgress = apps.get_model('books', 'EBookProgress')
|
||||
for p in EBookProgress.objects.all().iterator():
|
||||
p.furthest_fraction = p.scroll_fraction
|
||||
p.furthest_anchor = p.position_anchor
|
||||
p.read_at = int(p.updated_at.timestamp() * 1000)
|
||||
p.save(update_fields=['furthest_fraction', 'furthest_anchor', 'read_at'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0004_ebook_is_read'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ebookprogress',
|
||||
name='read_at',
|
||||
field=models.BigIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ebookprogress',
|
||||
name='device',
|
||||
field=models.CharField(blank=True, default='', max_length=40),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ebookprogress',
|
||||
name='furthest_fraction',
|
||||
field=models.FloatField(default=0.0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ebookprogress',
|
||||
name='furthest_anchor',
|
||||
field=models.CharField(blank=True, default='', max_length=30),
|
||||
),
|
||||
migrations.RunPython(keep_what_we_know, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -19,10 +19,17 @@ class EBook(models.Model):
|
|||
|
||||
|
||||
class EBookProgress(models.Model):
|
||||
"""Where to continue a book: the place it was last read, on any device
|
||||
(docs/progress-sync.md), plus the furthest place ever reached."""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
|
||||
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
|
||||
scroll_fraction = models.FloatField(default=0.0)
|
||||
position_anchor = models.CharField(max_length=30, blank=True, default='')
|
||||
# When the reader was at that place (ms since epoch, server clock); null before 2026-10-12.
|
||||
read_at = models.BigIntegerField(null=True, blank=True)
|
||||
device = models.CharField(max_length=40, blank=True, default='')
|
||||
furthest_fraction = models.FloatField(default=0.0)
|
||||
furthest_anchor = models.CharField(max_length=30, blank=True, default='')
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
162
books/tests.py
Normal file
162
books/tests.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""The reading position: the place last read wins (docs/progress-sync.md)."""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.models import ApiToken
|
||||
from .models import EBook, EBookProgress
|
||||
|
||||
NOW = 1_760_270_000_000 # the server's clock in these tests (ms)
|
||||
MIN = 60_000
|
||||
|
||||
|
||||
class ProgressTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
||||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {ApiToken.objects.create(user=self.user).token}'}
|
||||
self.book = EBook.objects.create(user=self.user, meta_ct='m', meta_iv='i', data_ct='d', data_iv='i')
|
||||
self.now = NOW
|
||||
clock = patch('books.views._now_ms', side_effect=lambda: self.now)
|
||||
clock.start()
|
||||
self.addCleanup(clock.stop)
|
||||
|
||||
def save(self, anchor, frac=None, read_at=None, sent_at=None, device='Handy', **extra):
|
||||
body = {'scroll_fraction': frac if frac is not None else 0.1, 'position_anchor': anchor, **extra}
|
||||
if read_at is not None:
|
||||
body.update(read_at=read_at, sent_at=sent_at if sent_at is not None else self.now, device=device)
|
||||
resp = self.client.post(f'/books/{self.book.id}/progress/', json.dumps(body),
|
||||
content_type='application/json', **self.auth)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return resp.json()
|
||||
|
||||
def state(self):
|
||||
return EBookProgress.objects.get(book=self.book)
|
||||
|
||||
# -- the new rule
|
||||
|
||||
def test_newer_place_wins_even_if_it_is_earlier_in_the_book(self):
|
||||
self.save('300:0.5', read_at=NOW - 10 * MIN, device='Handy')
|
||||
r = self.save('12:0.0', read_at=NOW - 1 * MIN, device='diora Desktop')
|
||||
self.assertTrue(r['stored'])
|
||||
p = self.state()
|
||||
self.assertEqual((p.position_anchor, p.device, p.read_at), ('12:0.0', 'diora Desktop', NOW - MIN))
|
||||
self.assertEqual(p.furthest_anchor, '300:0.5')
|
||||
|
||||
def test_an_older_write_arriving_late_does_not_win(self):
|
||||
# The phone read offline at 10:00 and syncs at 12:00; the desktop read at 11:00.
|
||||
self.save('40:0.0', read_at=NOW - 60 * MIN, device='diora Desktop')
|
||||
r = self.save('90:0.0', read_at=NOW - 120 * MIN, device='Handy')
|
||||
self.assertFalse(r['stored'])
|
||||
self.assertEqual(r['position_anchor'], '40:0.0')
|
||||
p = self.state()
|
||||
self.assertEqual((p.position_anchor, p.device), ('40:0.0', 'diora Desktop'))
|
||||
self.assertEqual(p.furthest_anchor, '90:0.0', 'the late write still counts as reached')
|
||||
|
||||
def test_resaving_an_unchanged_place_keeps_its_time_and_cannot_win(self):
|
||||
self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy')
|
||||
self.save('70:0.0', read_at=NOW - 2 * MIN, device='diora Desktop')
|
||||
# The phone, idle since, saves its place again on going to the background.
|
||||
self.now += MIN
|
||||
r = self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy')
|
||||
self.assertFalse(r['stored'])
|
||||
self.assertEqual(self.state().position_anchor, '70:0.0')
|
||||
|
||||
def test_the_device_clock_error_is_taken_out(self):
|
||||
# A tolino 3 hours behind: it read one minute ago by its clock, and sends now by its clock.
|
||||
behind = 3 * 60 * MIN
|
||||
self.save('10:0.0', read_at=NOW - 30 * MIN, device='Handy')
|
||||
r = self.save('20:0.0', read_at=NOW - behind - MIN, sent_at=NOW - behind, device='tolino')
|
||||
self.assertTrue(r['stored'])
|
||||
self.assertEqual(self.state().read_at, NOW - MIN)
|
||||
|
||||
def test_nothing_wins_from_the_future(self):
|
||||
self.save('10:0.0', read_at=NOW + 24 * 60 * MIN, sent_at=NOW)
|
||||
self.assertEqual(self.state().read_at, NOW)
|
||||
self.now += MIN
|
||||
self.assertTrue(self.save('11:0.0', read_at=self.now, sent_at=self.now)['stored'])
|
||||
|
||||
def test_equal_times_take_the_new_write(self):
|
||||
self.save('10:0.0', read_at=NOW - MIN)
|
||||
self.assertTrue(self.save('11:0.0', read_at=NOW - MIN)['stored'])
|
||||
|
||||
def test_force_sets_the_place_now(self):
|
||||
self.save('300:0.0', read_at=NOW - MIN)
|
||||
self.assertTrue(self.save('0:0.0', frac=0.0, force=True)['stored'])
|
||||
p = self.state()
|
||||
self.assertEqual((p.position_anchor, p.read_at), ('0:0.0', NOW))
|
||||
self.assertEqual(p.furthest_anchor, '300:0.0')
|
||||
|
||||
def test_furthest_only_grows(self):
|
||||
self.save('80:0.0', read_at=NOW - 3 * MIN)
|
||||
self.save('20:0.0', read_at=NOW - 2 * MIN)
|
||||
self.save('81:0.0', read_at=NOW - 9 * MIN)
|
||||
p = self.state()
|
||||
self.assertEqual((p.position_anchor, p.furthest_anchor), ('20:0.0', '81:0.0'))
|
||||
|
||||
def test_pdfs_compare_by_fraction(self):
|
||||
self.save('', frac=0.7, read_at=NOW - 2 * MIN)
|
||||
self.save('', frac=0.3, read_at=NOW - MIN)
|
||||
p = self.state()
|
||||
self.assertEqual((p.scroll_fraction, p.furthest_fraction), (0.3, 0.7))
|
||||
|
||||
def test_response_is_the_state_after_the_write(self):
|
||||
r = self.save('5:0.25', frac=0.05, read_at=NOW - MIN, device='Browser')
|
||||
self.assertEqual(r['server_now'], NOW)
|
||||
for key in ('scroll_fraction', 'position_anchor', 'read_at', 'device',
|
||||
'furthest_fraction', 'furthest_anchor', 'last_read'):
|
||||
self.assertIn(key, r)
|
||||
self.assertEqual(r['read_at'], NOW - MIN)
|
||||
|
||||
def test_implausible_times_count_as_an_old_app(self):
|
||||
self.save('10:0.0', read_at=NOW - MIN)
|
||||
# read_at=True or seconds instead of ms are not times: only forward, like an old app.
|
||||
self.assertFalse(self.save('5:0.0', read_at=True)['stored'])
|
||||
self.assertFalse(self.save('5:0.0', read_at=1_760_270_000)['stored'])
|
||||
|
||||
# -- apps from before the change (no read_at)
|
||||
|
||||
def test_old_apps_only_move_forward(self):
|
||||
self.save('50:0.0', read_at=NOW - MIN)
|
||||
self.assertFalse(self.save('20:0.0')['stored'])
|
||||
self.assertTrue(self.save('60:0.0')['stored'])
|
||||
self.assertEqual(self.state().read_at, NOW)
|
||||
|
||||
def test_old_apps_resaving_the_same_place_change_nothing(self):
|
||||
self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy')
|
||||
self.now += MIN
|
||||
self.assertFalse(self.save('50:0.0')['stored'])
|
||||
p = self.state()
|
||||
self.assertEqual((p.read_at, p.device), (NOW - 5 * MIN, 'Handy'))
|
||||
|
||||
def test_first_write_of_an_old_app_is_stored(self):
|
||||
self.assertTrue(self.save('0:0.0', frac=0.0)['stored'])
|
||||
|
||||
def test_old_force_still_works(self):
|
||||
self.save('50:0.0', read_at=NOW - MIN)
|
||||
self.assertTrue(self.save('1:0.0', force=True)['stored'])
|
||||
|
||||
# -- reading
|
||||
|
||||
def test_book_list_and_snapshot_carry_the_state(self):
|
||||
self.save('7:0.5', frac=0.2, read_at=NOW - MIN, device='Browser')
|
||||
resp = self.client.get('/books/', **self.auth)
|
||||
b = resp.json()[0]
|
||||
self.assertEqual(resp['X-Server-Now'], str(NOW))
|
||||
self.assertEqual((b['position_anchor'], b['read_at'], b['device'], b['furthest_anchor']),
|
||||
('7:0.5', NOW - MIN, 'Browser', '7:0.5'))
|
||||
self.assertTrue(b['last_read'].startswith('2025-10-12T'))
|
||||
snap = self.client.get('/api/sync/?only=books', **self.auth).json()
|
||||
p = snap['book_progress'][0]
|
||||
self.assertEqual((p['book_id'], p['read_at'], p['device']), (self.book.id, NOW - MIN, 'Browser'))
|
||||
self.assertIn('server_now', snap)
|
||||
|
||||
def test_rows_from_before_have_their_save_time_as_read_at(self):
|
||||
p = EBookProgress.objects.create(user=self.user, book=self.book, scroll_fraction=0.4, position_anchor='9:0.0')
|
||||
b = self.client.get('/books/', **self.auth).json()[0]
|
||||
self.assertEqual(b['read_at'], int(p.updated_at.timestamp() * 1000))
|
||||
|
||||
def test_a_never_opened_book(self):
|
||||
b = self.client.get('/books/', **self.auth).json()[0]
|
||||
self.assertEqual((b['read_at'], b['last_read'], b['position_anchor']), (None, None, ''))
|
||||
110
books/views.py
110
books/views.py
|
|
@ -1,10 +1,13 @@
|
|||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
|
@ -247,11 +250,40 @@ def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac):
|
|||
"""True if the new reading position is at least as far into the book as the
|
||||
old one. Compares by anchor block index (precise, decoupled from layout);
|
||||
falls back to scroll_fraction only when an anchor is missing (PDFs)."""
|
||||
nb, ni = _anchor_parts(new_anchor)
|
||||
ob, oi = _anchor_parts(old_anchor)
|
||||
if nb >= 0 and ob >= 0:
|
||||
return ni >= oi if nb == ob else nb >= ob
|
||||
return new_frac >= old_frac
|
||||
return _cmp_progress(new_anchor, new_frac, old_anchor, old_frac) >= 0
|
||||
|
||||
|
||||
def _cmp_progress(a_anchor, a_frac, b_anchor, b_frac):
|
||||
"""> 0 if A is further into the book than B, < 0 if behind, 0 if level."""
|
||||
ab, ai = _anchor_parts(a_anchor)
|
||||
bb, bi = _anchor_parts(b_anchor)
|
||||
if ab >= 0 and bb >= 0:
|
||||
if ab != bb:
|
||||
return ab - bb
|
||||
return (ai > bi) - (ai < bi)
|
||||
return (a_frac > b_frac) - (a_frac < b_frac)
|
||||
|
||||
|
||||
def _now_ms():
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def progress_state(p):
|
||||
"""A book's reading state as every client sees it (docs/progress-sync.md);
|
||||
p may be None (never opened)."""
|
||||
if p is None:
|
||||
return {'scroll_fraction': 0.0, 'position_anchor': '', 'read_at': None, 'device': '',
|
||||
'furthest_fraction': 0.0, 'furthest_anchor': '', 'last_read': None}
|
||||
read_at = p.read_at if p.read_at is not None else int(p.updated_at.timestamp() * 1000)
|
||||
return {
|
||||
'scroll_fraction': p.scroll_fraction,
|
||||
'position_anchor': p.position_anchor,
|
||||
'read_at': read_at,
|
||||
'device': p.device,
|
||||
'furthest_fraction': p.furthest_fraction,
|
||||
'furthest_anchor': p.furthest_anchor,
|
||||
'last_read': datetime.fromtimestamp(read_at / 1000, tz=dt_timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
|
|
@ -264,21 +296,17 @@ def book_list(request):
|
|||
)
|
||||
for b in books:
|
||||
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
||||
# Include saved scroll_fraction for each book
|
||||
progress_map = {
|
||||
p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor)
|
||||
for p in EBookProgress.objects.filter(user=request.user)
|
||||
}
|
||||
progress_map = {p.book_id: p for p in EBookProgress.objects.filter(user=request.user)}
|
||||
highlighted_ids = set(
|
||||
EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True)
|
||||
)
|
||||
for b in books:
|
||||
prog = progress_map.get(b['id'])
|
||||
b['scroll_fraction'] = prog[0] if prog else 0.0
|
||||
b['last_read'] = prog[1].isoformat() if prog else None
|
||||
b['position_anchor'] = prog[2] if prog else ''
|
||||
b.update(progress_state(progress_map.get(b['id'])))
|
||||
b['has_highlights'] = b['id'] in highlighted_ids
|
||||
return JsonResponse(books, safe=False)
|
||||
response = JsonResponse(books, safe=False)
|
||||
# The list is a bare array (old clients expect that): the server clock goes in a header.
|
||||
response['X-Server-Now'] = str(_now_ms())
|
||||
return response
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
|
@ -497,22 +525,50 @@ def save_progress(request, pk):
|
|||
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
|
||||
position_anchor = raw_anchor
|
||||
|
||||
force = bool(body.get('force', False))
|
||||
force = body.get('force') is True
|
||||
device = body.get('device') if isinstance(body.get('device'), str) else ''
|
||||
now = _now_ms()
|
||||
read_at = _client_ms(body.get('read_at'))
|
||||
sent_at = _client_ms(body.get('sent_at'))
|
||||
|
||||
progress, _ = EBookProgress.objects.get_or_create(
|
||||
user=request.user,
|
||||
book=book,
|
||||
)
|
||||
# Always advance to the furthest-read position (by anchor block index, so a
|
||||
# transiently wrong scroll_fraction can't freeze the position) unless the
|
||||
# client explicitly forces a reset (e.g. "start over" button in the reader).
|
||||
if force or _progress_is_further(position_anchor, scroll_fraction,
|
||||
progress.position_anchor, progress.scroll_fraction):
|
||||
with transaction.atomic():
|
||||
progress, created = EBookProgress.objects.select_for_update().get_or_create(user=request.user, book=book)
|
||||
if created:
|
||||
progress.read_at = None
|
||||
if force:
|
||||
when, store = now, True
|
||||
elif read_at is not None:
|
||||
# The device's clock may be off: move its time onto ours by the
|
||||
# difference at sending, and nothing counts as later than now.
|
||||
when = read_at + (now - sent_at) if sent_at is not None else read_at
|
||||
when = min(when, now)
|
||||
store = progress.read_at is None or when >= progress.read_at
|
||||
else:
|
||||
# An app from before 2026-10-12: only forward, as it used to be — but
|
||||
# strictly, so re-saving the same place changes nothing.
|
||||
when = now
|
||||
store = created or _cmp_progress(position_anchor, scroll_fraction,
|
||||
progress.position_anchor, progress.scroll_fraction) > 0
|
||||
if store:
|
||||
progress.scroll_fraction = scroll_fraction
|
||||
progress.position_anchor = position_anchor
|
||||
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
|
||||
progress.read_at = when
|
||||
progress.device = device[:40]
|
||||
if _cmp_progress(position_anchor, scroll_fraction,
|
||||
progress.furthest_anchor, progress.furthest_fraction) > 0 or created:
|
||||
progress.furthest_fraction = scroll_fraction
|
||||
progress.furthest_anchor = position_anchor
|
||||
progress.save()
|
||||
|
||||
return JsonResponse({'ok': True})
|
||||
return JsonResponse({'ok': True, 'stored': store, 'server_now': now, **progress_state(progress)})
|
||||
|
||||
|
||||
def _client_ms(value):
|
||||
"""A time from a client (ms since epoch) or None if it isn't a plausible one."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
value = int(value)
|
||||
return value if 10 ** 12 <= value < 10 ** 14 else None
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
|
|
|||
71
docs/progress-sync.md
Normal file
71
docs/progress-sync.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Reading position: the last place read wins
|
||||
|
||||
Since 2026-10-12 the place to continue a book is **where it was last read, on any device**, not the
|
||||
furthest place ever reached. The furthest place is still kept, as a hint ("Weiteste Stelle").
|
||||
This file is the contract between the server (books/views.py `save_progress`), the web reader
|
||||
(static/js/app.js), diora-android (Ops.kt / SyncEngine.kt / native-*.js) and diora-desktop
|
||||
(src/main/ops.js / sync.js). They must apply the same rules.
|
||||
|
||||
## The state per book
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `scroll_fraction`, `position_anchor` | the continue point (anchor `"block:inner"`, empty for PDFs) |
|
||||
| `read_at` | when the reader was **at** that place, ms since epoch, **server clock** |
|
||||
| `device` | who read there (`"Browser"`, `"diora Desktop"`, `"Handy"`, `"tolino"` …), for "auf X weitergelesen" |
|
||||
| `furthest_fraction`, `furthest_anchor` | the furthest place ever reported (only ever grows) |
|
||||
|
||||
`last_read` in `/books/` is `read_at` as ISO time (it used to be the time of the last save attempt).
|
||||
|
||||
## Writing: `POST /books/<id>/progress/`
|
||||
|
||||
```json
|
||||
{"scroll_fraction": 0.42, "position_anchor": "187:0.62", "read_at": 1760270000000,
|
||||
"sent_at": 1760270004000, "device": "diora Desktop"}
|
||||
```
|
||||
|
||||
* `read_at` is **the moment the reader arrived at this position** (the last time the visible position
|
||||
changed because of the reader), in the **device's** clock, not the moment of saving. Saving an
|
||||
unchanged position again — every 10 s, on hide, on close, on unload — therefore carries the old time
|
||||
and can never beat a newer position from another device. A device that only *shows* a position it
|
||||
got from the server (just opened, nothing moved) reports the server's `read_at` back unchanged.
|
||||
* `sent_at` is the device clock **when the request leaves** (an outbox sets it at flush time, not
|
||||
when queuing). The server corrects the device's clock error with it:
|
||||
`t = read_at + (server_now − sent_at)`, then `t = min(t, server_now)`.
|
||||
So a tolino whose clock is hours off still orders correctly, and nothing can win "from the future".
|
||||
* The server stores the position if `t >= stored read_at` (or nothing is stored, or `force`).
|
||||
Older writes (an offline device catching up late) are **not** stored as the continue point.
|
||||
* Every write — stored or not — raises `furthest_*` if it is further (same comparison as before:
|
||||
anchor block, then inner fraction; `scroll_fraction` when an anchor is missing).
|
||||
* `force: true` (the explicit "Diese Stelle als Fortschritt setzen") stores regardless, with `t = server_now`.
|
||||
* **Old clients** (no `read_at`): stored only if **strictly further** than the stored position
|
||||
(the old rule, but `>` so an idle old client re-saving its place changes nothing), with `t = server_now`.
|
||||
This keeps not-yet-updated apps from pulling the continue point back.
|
||||
* The response is the state after the write:
|
||||
`{"ok": true, "stored": bool, "scroll_fraction", "position_anchor", "read_at", "device",
|
||||
"furthest_fraction", "furthest_anchor", "server_now"}`.
|
||||
|
||||
## Reading
|
||||
|
||||
`/books/` and `/api/sync/` (`book_progress`) carry all fields of the state, `read_at` in ms (server clock),
|
||||
plus `server_now` (ms) so a client can estimate its clock offset `server_now − device_now`.
|
||||
|
||||
## Clients
|
||||
|
||||
* **Local apply / outbox** (Android, desktop, web cache): the same rule as the server, in server time:
|
||||
a local write converts its `read_at` with the last known offset (0 if none), stores it if newer
|
||||
than or equal to what the local list holds. Two queued writes for the same book: keep the one with
|
||||
the later `read_at` (force stays sticky). After a pull, a pending write is reapplied with that rule:
|
||||
newer than the server's → it shows (and is sent); older → the server's position shows (it's still sent,
|
||||
for `furthest`).
|
||||
* **Never save before the position is restored** after opening a book, and never save a position the
|
||||
reader didn't reach by reading: while a jump is tentative ("Zurück zum Text" after a footnote or link,
|
||||
"Weit gescrollt — Zurückspringen"), the previous position and its `read_at` are what gets saved, until
|
||||
the reader returns (nothing changes) or carries on reading there (then the jump target counts, with
|
||||
its own time).
|
||||
* **Opening a book** uses the newer of server state and local (not yet sent) state, by `read_at`.
|
||||
* **Coming back to an open book** (window/app in front again, or after a pull): if the server holds a
|
||||
newer position from another device than the one shown, offer it: "Auf <device> weitergelesen —
|
||||
Springen". Do not move the page without asking.
|
||||
* If `furthest_*` is clearly ahead of the continue point when a book opens, offer
|
||||
"Weiteste Stelle: 63 % — Springen".
|
||||
377
static/js/app.js
377
static/js/app.js
|
|
@ -287,7 +287,8 @@ window.addEventListener('beforeunload', () => {
|
|||
}
|
||||
// Flush cached encrypted payloads for reader data (encryption is async so we
|
||||
// use pre-computed blobs stored by the debounced savers)
|
||||
if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body);
|
||||
// The last position written (it's in the outbox too, in case the beacon is lost).
|
||||
if (_lastProgressBeacon) navigator.sendBeacon(`/books/${_lastProgressBeacon.book}/progress/`, _progressBody(_lastProgressBeacon));
|
||||
if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body);
|
||||
if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body);
|
||||
});
|
||||
|
|
@ -298,6 +299,8 @@ document.addEventListener('visibilitychange', () => {
|
|||
if (highlightsDirty) saveHighlights();
|
||||
saveReaderProgress();
|
||||
}
|
||||
// Back in front: maybe the book was read on another device meanwhile.
|
||||
if (document.visibilityState === 'visible' && currentBookId) _checkNewerPositionElsewhere();
|
||||
});
|
||||
|
||||
// Cached beacon payloads — updated after each successful encrypt in save functions
|
||||
|
|
@ -2080,6 +2083,7 @@ function _dismissScrollJumpToast() {
|
|||
_scrollJumpToastEl.remove();
|
||||
_scrollJumpToastEl = null;
|
||||
}
|
||||
if (_readPos && _readPos.hold === 'jump') _readPos.hold = false;
|
||||
}
|
||||
|
||||
function _showScrollJumpToast(backTop) {
|
||||
|
|
@ -2089,6 +2093,8 @@ function _showScrollJumpToast(backTop) {
|
|||
toast.innerHTML = `<span>Weit gescrollt</span><button type="button" class="reader-toast-btn">Zurückspringen</button>`;
|
||||
document.body.appendChild(toast);
|
||||
_scrollJumpToastEl = toast;
|
||||
// Until it's gone, the place before the jump stays the reading position.
|
||||
if (_readPos && !_readPos.hold) _readPos.hold = 'jump';
|
||||
toast.querySelector('.reader-toast-btn').addEventListener('click', () => {
|
||||
const contentEl = $('reader-content');
|
||||
if (contentEl) {
|
||||
|
|
@ -2096,8 +2102,16 @@ function _showScrollJumpToast(backTop) {
|
|||
contentEl.scrollTo({top: backTop, behavior: 'smooth'});
|
||||
}
|
||||
_dismissScrollJumpToast();
|
||||
// Back where it was: that's no new reading.
|
||||
_readerInteractedAt = 0;
|
||||
});
|
||||
setTimeout(_dismissScrollJumpToast, 6000);
|
||||
setTimeout(() => {
|
||||
if (_scrollJumpToastEl !== toast) return;
|
||||
_dismissScrollJumpToast();
|
||||
// Stayed there: the far place is where the reading goes on.
|
||||
_noteReaderInteraction();
|
||||
if (_readPos) _readPos.key = null;
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
function _checkScrollJump(contentEl) {
|
||||
|
|
@ -2294,6 +2308,203 @@ function _cmpProgress(anchorA, fracA, anchorB, fracB) {
|
|||
return (fracA || 0) - (fracB || 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading position: the place last read wins, on any device (docs/progress-sync.md).
|
||||
//
|
||||
// A position counts as read when the reader got there by reading (scrolling,
|
||||
// turning pages, choosing a chapter); its time is that moment, not the time it
|
||||
// is saved. So re-saving an unchanged place, on a timer or on closing, carries
|
||||
// the old time and can never beat a newer place read on another device. A book
|
||||
// just opened at the server's place has read nothing here yet: nothing is sent.
|
||||
// Own writes wait in a small outbox (localStorage) until the server has them.
|
||||
// ---------------------------------------------------------------------------
|
||||
let _serverOffset = 0; // the server's clock minus this device's (ms)
|
||||
let _readPos = null; // the open book: {bookId, ready, key, readAt (device ms), moved, hold}
|
||||
let _readerInteractedAt = 0;
|
||||
|
||||
function _progressDevice() {
|
||||
return window.DIORA_DEVICE_NAME || 'Browser';
|
||||
}
|
||||
|
||||
function _noteServerNow(ms) {
|
||||
if (typeof ms === 'number' && ms > 1e12) _serverOffset = ms - Date.now();
|
||||
}
|
||||
|
||||
function _noteServerNowHeader(res) {
|
||||
try { _noteServerNow(Number(res.headers.get('X-Server-Now'))); } catch (e) {}
|
||||
}
|
||||
|
||||
// The reader did something (scroll wheel, touch, key, mouse): the next change
|
||||
// of position is reading, not a layout settling.
|
||||
function _noteReaderInteraction() {
|
||||
_readerInteractedAt = Date.now();
|
||||
if (_readPos && _readPos.hold === 'until-moved') _readPos.hold = false;
|
||||
}
|
||||
window.__dioraReaderInteraction = _noteReaderInteraction;
|
||||
|
||||
// While a jump is only a look (a footnote's place, "Zurück zum Text"), the
|
||||
// place before it stays the reading position. true: until the next
|
||||
// interaction; false: released now.
|
||||
window.__dioraHoldProgress = function (on) {
|
||||
if (_readPos) _readPos.hold = on ? 'until-moved' : false;
|
||||
};
|
||||
|
||||
function _positionKey(anchor, fraction) {
|
||||
return anchor || 'f' + (fraction || 0).toFixed(4);
|
||||
}
|
||||
|
||||
// Called with every computed position: a change that follows an interaction is reading.
|
||||
function _noteReadingPosition(anchor, fraction) {
|
||||
if (!_readPos || !_readPos.ready || _readPos.hold) return;
|
||||
const key = _positionKey(anchor, fraction);
|
||||
if (key === _readPos.key) return;
|
||||
_readPos.key = key;
|
||||
if (Date.now() - _readerInteractedAt < 10000) {
|
||||
_readPos.readAt = Date.now();
|
||||
_readPos.moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
function _progressOutboxKey() {
|
||||
return `diora_progress_outbox_${window.USER_ID || 0}`;
|
||||
}
|
||||
|
||||
function _progressOutbox() {
|
||||
try { return JSON.parse(localStorage.getItem(_progressOutboxKey()) || '{}') || {}; } catch (e) { return {}; }
|
||||
}
|
||||
|
||||
function _progressOutboxPut(entry) {
|
||||
try {
|
||||
const box = _progressOutbox();
|
||||
const old = box[entry.book];
|
||||
if (old && !entry.force && old.read_at > entry.read_at) return;
|
||||
box[entry.book] = entry;
|
||||
localStorage.setItem(_progressOutboxKey(), JSON.stringify(box));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function _progressOutboxDone(bookId, readAt) {
|
||||
try {
|
||||
const box = _progressOutbox();
|
||||
if (box[bookId] && box[bookId].read_at === readAt) {
|
||||
delete box[bookId];
|
||||
localStorage.setItem(_progressOutboxKey(), JSON.stringify(box));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function _progressBody(e) {
|
||||
return JSON.stringify({
|
||||
scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: e.read_at,
|
||||
sent_at: Date.now(), device: e.device, ...(e.force && {force: true}),
|
||||
});
|
||||
}
|
||||
|
||||
// Send one queued position; true once the server has it (stored or not: it decides).
|
||||
async function _sendProgress(e) {
|
||||
try {
|
||||
const res = await fetch(`/books/${e.book}/progress/`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||
body: _progressBody(e),
|
||||
});
|
||||
if (!(res.ok || (res.status >= 400 && res.status < 500))) return false;
|
||||
try { _noteServerNow((await res.json()).server_now); } catch (err) {}
|
||||
_progressOutboxDone(e.book, e.read_at);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let _progressFlushing = null;
|
||||
function _flushProgressOutbox() {
|
||||
if (!_progressFlushing) {
|
||||
_progressFlushing = (async () => {
|
||||
for (const e of Object.values(_progressOutbox())) await _sendProgress(e);
|
||||
})().finally(() => { _progressFlushing = null; });
|
||||
}
|
||||
return _progressFlushing;
|
||||
}
|
||||
|
||||
// A book's state from the server list, made newer by an own write the server doesn't have yet.
|
||||
function _withPendingProgress(b) {
|
||||
const e = _progressOutbox()[b.id];
|
||||
if (!e) return b;
|
||||
const at = e.read_at + _serverOffset;
|
||||
if (b.read_at != null && at < b.read_at && !e.force) return b;
|
||||
return {...b, scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: at,
|
||||
device: e.device, last_read: new Date(at).toISOString()};
|
||||
}
|
||||
|
||||
function _bookRecency(b) {
|
||||
if (typeof b.read_at === 'number') return b.read_at;
|
||||
const t = b.last_read ? Date.parse(b.last_read) : NaN;
|
||||
return isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
function _readerToast(text, button, onClick, ms) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'reader-toast reader-toast-action';
|
||||
toast.innerHTML = '<span></span><button type="button" class="reader-toast-btn"></button>';
|
||||
toast.querySelector('span').textContent = text;
|
||||
toast.querySelector('button').textContent = button;
|
||||
document.body.appendChild(toast);
|
||||
const done = () => toast.remove();
|
||||
toast.querySelector('button').addEventListener('click', () => { done(); onClick(); });
|
||||
setTimeout(done, ms || 10000);
|
||||
return toast;
|
||||
}
|
||||
|
||||
function _jumpToPosition(anchor, fraction) {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
_suppressScrollJumpDetect();
|
||||
if (currentPdfDoc && readerSettings.pdfPaginated && pdfTotalPages > 1) {
|
||||
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
|
||||
enterPdfPaginatedMode();
|
||||
} else if (currentPdfDoc) {
|
||||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||||
} else if (!restoreFromAnchor(contentEl, anchor)) {
|
||||
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction));
|
||||
}
|
||||
}
|
||||
|
||||
// Another device read this book after the place shown here: offer to go there.
|
||||
let _newerOfferShown = null;
|
||||
async function _checkNewerPositionElsewhere() {
|
||||
const pos = _readPos;
|
||||
if (!pos || !pos.ready || !currentBookId || pos.bookId !== currentBookId) return;
|
||||
let b;
|
||||
try {
|
||||
const res = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(res);
|
||||
b = (await res.json()).find(x => x.id === pos.bookId);
|
||||
} catch (e) { return; }
|
||||
if (!b || b.read_at == null || _readPos !== pos) return;
|
||||
if (b.read_at <= pos.readAt + _serverOffset + 3000) return;
|
||||
if (_positionKey(b.position_anchor || '', b.scroll_fraction) === pos.key) return;
|
||||
const offerKey = `${pos.bookId}:${b.read_at}`;
|
||||
if (_newerOfferShown === offerKey) return;
|
||||
_newerOfferShown = offerKey;
|
||||
const where = b.device ? `Auf ${b.device}` : 'Auf einem anderen Gerät';
|
||||
_readerToast(`${where} weitergelesen · ${_formatBookPercent(b.scroll_fraction || 0)} %`, 'Dorthin', () => {
|
||||
if (_readPos !== pos) return;
|
||||
_jumpToPosition(b.position_anchor || '', b.scroll_fraction || 0);
|
||||
// That place and its time are the server's: nothing new to tell it.
|
||||
pos.key = null;
|
||||
pos.readAt = b.read_at - _serverOffset;
|
||||
pos.moved = false;
|
||||
setTimeout(() => {
|
||||
const contentEl = $('reader-content');
|
||||
if (_readPos === pos && contentEl) {
|
||||
pos.key = currentPdfDoc ? _positionKey('', b.scroll_fraction || 0) : _positionKey(getPositionAnchor(contentEl), 0);
|
||||
}
|
||||
}, 300);
|
||||
}, 15000);
|
||||
}
|
||||
window.__dioraCheckNewerPosition = _checkNewerPositionElsewhere;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book cache — IndexedDB, evict after 4 weeks of inactivity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2497,7 +2708,8 @@ async function _loadCachedBookMeta() {
|
|||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function _updateCachedBookProgress(bookId, fraction, anchor, force = false) {
|
||||
async function _updateCachedBookProgress(bookId, fraction, anchor, force = false, readAt = null) {
|
||||
const at = (readAt == null ? Date.now() : readAt) + _serverOffset;
|
||||
try {
|
||||
const db = await _openBookCacheDb();
|
||||
await new Promise(resolve => {
|
||||
|
|
@ -2506,11 +2718,17 @@ async function _updateCachedBookProgress(bookId, fraction, anchor, force = false
|
|||
const req = store.get(bookId);
|
||||
req.onsuccess = e => {
|
||||
const entry = e.target.result;
|
||||
// Only advance (or on force), so a transiently wrong save can't lose progress.
|
||||
if (entry && (force || _cmpProgress(anchor, fraction, entry.position_anchor, entry.scroll_fraction) >= 0)) {
|
||||
// The place read last, as on the server (docs/progress-sync.md).
|
||||
if (entry && (force || entry.read_at == null || at >= entry.read_at)) {
|
||||
entry.scroll_fraction = fraction;
|
||||
entry.position_anchor = anchor;
|
||||
entry.last_read = new Date().toISOString();
|
||||
entry.read_at = at;
|
||||
entry.device = _progressDevice();
|
||||
entry.last_read = new Date(at).toISOString();
|
||||
if (_cmpProgress(anchor, fraction, entry.furthest_anchor || '', entry.furthest_fraction || 0) > 0) {
|
||||
entry.furthest_anchor = anchor;
|
||||
entry.furthest_fraction = fraction;
|
||||
}
|
||||
store.put(entry);
|
||||
}
|
||||
};
|
||||
|
|
@ -2641,7 +2859,9 @@ async function loadBookList() {
|
|||
|
||||
try {
|
||||
listEl.innerHTML = '<p class="muted">Bücherliste wird vom Server geholt …</p>';
|
||||
_flushProgressOutbox(); // own positions the server doesn't have yet
|
||||
const res = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(res);
|
||||
if (!res.ok) {
|
||||
listEl.innerHTML = `<p class="muted">Serverfehler ${res.status} beim Laden der Bücher.</p>`;
|
||||
return;
|
||||
|
|
@ -2673,48 +2893,23 @@ async function loadBookList() {
|
|||
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||||
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 || '', pages: meta.pages || 0};
|
||||
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, 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', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_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) {
|
||||
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
|
||||
decrypted.push({id: b.id, title: `Buch #${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});
|
||||
}
|
||||
}
|
||||
// If local cache is further ahead than the server, push it and use it for display.
|
||||
// Server uses max-semantics so this is always safe.
|
||||
const _localMeta = await _loadCachedBookMeta();
|
||||
const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b]));
|
||||
for (const b of decrypted) {
|
||||
const loc = _localById[b.id];
|
||||
if (loc && _cmpProgress(loc.position_anchor, loc.scroll_fraction, b.position_anchor, b.scroll_fraction) > 0) {
|
||||
fetch(`/books/${b.id}/progress/`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||
body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}),
|
||||
}).catch(() => {});
|
||||
b.scroll_fraction = loc.scroll_fraction;
|
||||
b.position_anchor = loc.position_anchor;
|
||||
b.last_read = loc.last_read;
|
||||
decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
||||
}
|
||||
}
|
||||
// An own position the server doesn't have yet (sent above) shows if it's the newer one.
|
||||
for (let i = 0; i < decrypted.length; i++) decrypted[i] = _withPendingProgress(decrypted[i]);
|
||||
|
||||
decrypted.sort((a, b) => {
|
||||
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
||||
if (a.last_read) return -1;
|
||||
if (b.last_read) return 1;
|
||||
return b.uploaded_at.localeCompare(a.uploaded_at);
|
||||
});
|
||||
decrypted.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || b.uploaded_at.localeCompare(a.uploaded_at));
|
||||
_saveBookMeta(decrypted); // persist for offline
|
||||
renderBookList(decrypted);
|
||||
} catch (e) {
|
||||
// Network error — try offline fallback
|
||||
const cachedBooks = await _loadCachedBookMeta();
|
||||
const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress);
|
||||
if (cachedBooks.length) {
|
||||
cachedBooks.sort((a, b) => {
|
||||
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
||||
if (a.last_read) return -1;
|
||||
if (b.last_read) return 1;
|
||||
return (b.uploaded_at || '').localeCompare(a.uploaded_at || '');
|
||||
});
|
||||
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (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 || '', pages: b.pages || 0};
|
||||
const uploadArea = $('book-upload-area');
|
||||
if (uploadArea) uploadArea.style.display = 'none';
|
||||
|
|
@ -3623,6 +3818,8 @@ function _ttsClearHighlight() {
|
|||
// 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) {
|
||||
// Listening is reading: where the voice is counts as the reading position.
|
||||
_noteReaderInteraction();
|
||||
_ttsClearHighlight();
|
||||
const full = block.textContent;
|
||||
const idx = full.indexOf(sentence, fromOffset);
|
||||
|
|
@ -3840,7 +4037,6 @@ function exitReaderImmersiveMode() {
|
|||
// progress saved from that doubled DOM had block indices beyond the real book,
|
||||
// which then out-ranked every real position on the server.
|
||||
let _openBookInFlight = null;
|
||||
let _repairProgressAnchor = false; // stored anchor didn't fit this book
|
||||
|
||||
async function openBook(bookId) {
|
||||
if (_openBookInFlight) {
|
||||
|
|
@ -3857,7 +4053,8 @@ async function openBook(bookId) {
|
|||
}
|
||||
|
||||
async function _openBookOnce(bookId) {
|
||||
_repairProgressAnchor = false;
|
||||
// Nothing is saved for this book until its place is restored.
|
||||
_readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false};
|
||||
const overlay = $('reader-overlay');
|
||||
const contentEl = $('reader-content');
|
||||
const titleEl = $('reader-title');
|
||||
|
|
@ -4020,21 +4217,25 @@ async function _openBookOnce(bookId) {
|
|||
|
||||
// Restore scroll position — must happen BEFORE auto-save timer is started
|
||||
_suppressScrollJumpDetect();
|
||||
let openedProgress = null;
|
||||
try {
|
||||
let fraction = 0, anchor = '';
|
||||
let bookData = null;
|
||||
try {
|
||||
const progressRes = await fetch('/books/');
|
||||
const progressRes = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(progressRes);
|
||||
const allBooks = await progressRes.json();
|
||||
const bookData = allBooks.find(b => b.id === bookId);
|
||||
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||||
anchor = bookData ? (bookData.position_anchor || '') : '';
|
||||
bookData = allBooks.find(b => b.id === bookId) || null;
|
||||
} catch {
|
||||
// offline — use cached meta
|
||||
const cached = await _loadCachedBookMeta();
|
||||
const bookData = cached.find(b => b.id === bookId);
|
||||
bookData = cached.find(b => b.id === bookId) || null;
|
||||
}
|
||||
// The newer of the server's place and an own one it doesn't have yet.
|
||||
if (bookData) bookData = _withPendingProgress(bookData);
|
||||
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||||
anchor = bookData ? (bookData.position_anchor || '') : '';
|
||||
}
|
||||
openedProgress = bookData;
|
||||
_currentPositionAnchor = anchor;
|
||||
if (isPdf && readerSettings.pdfPaginated) {
|
||||
if (fraction > 0 && pdfTotalPages > 1)
|
||||
|
|
@ -4058,16 +4259,20 @@ async function _openBookOnce(bookId) {
|
|||
if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) {
|
||||
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction));
|
||||
}
|
||||
// An anchor past the end of this book can only come from a broken
|
||||
// render. The server keeps the furthest block index, so it would block
|
||||
// every real position from now on: overwrite it with the next save.
|
||||
const [storedBlock] = _anchorParts(anchor);
|
||||
if (storedBlock >= contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR).length) {
|
||||
_repairProgressAnchor = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// From here on, what the reader does is reading. The place shown has the
|
||||
// time it was read at (elsewhere, or here earlier): nothing new to send yet.
|
||||
if (_readPos && _readPos.bookId === bookId && currentBookId === bookId) {
|
||||
const shownAnchor = isPdf ? '' : getPositionAnchor(contentEl);
|
||||
const readAtServer = openedProgress && openedProgress.read_at != null ? openedProgress.read_at : null;
|
||||
_readPos.key = _positionKey(shownAnchor, openedProgress ? (openedProgress.scroll_fraction || 0) : 0);
|
||||
_readPos.readAt = readAtServer == null ? 0 : readAtServer - _serverOffset;
|
||||
_readPos.ready = true;
|
||||
_offerFurthestPosition(openedProgress);
|
||||
}
|
||||
|
||||
// Prime the "settled" baseline to wherever we just restored to, rather
|
||||
// than waiting to lazily pick up whatever scroll event happens to fire
|
||||
// first — that could otherwise be a later, unrelated layout correction.
|
||||
|
|
@ -4110,6 +4315,7 @@ async function _openBookOnce(bookId) {
|
|||
_anchorTrackDebounce = setTimeout(() => {
|
||||
_currentPositionAnchor = getPositionAnchor(contentEl);
|
||||
const f = _fractionFromAnchor(contentEl, _currentPositionAnchor);
|
||||
_noteReadingPosition(_currentPositionAnchor, f === null ? 0 : f);
|
||||
if (progressInput && f !== null && document.activeElement !== progressInput) {
|
||||
progressInput.value = _formatBookPercent(f);
|
||||
}
|
||||
|
|
@ -4205,6 +4411,19 @@ async function deleteBook(bookId) {
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Opened somewhere before the furthest place reached: offer it.
|
||||
function _offerFurthestPosition(p) {
|
||||
if (!p) return;
|
||||
const furthest = p.furthest_fraction || 0;
|
||||
if (furthest - (p.scroll_fraction || 0) < 0.02 || furthest >= 0.999) return;
|
||||
const pos = _readPos;
|
||||
_readerToast(`Weiteste Stelle: ${_formatBookPercent(furthest)} %`, 'Dorthin', () => {
|
||||
if (_readPos !== pos) return;
|
||||
_noteReaderInteraction();
|
||||
_jumpToPosition(p.furthest_anchor || '', furthest);
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
async function saveReaderProgress(force = false) {
|
||||
if (!currentBookId) return;
|
||||
const contentEl = $('reader-content');
|
||||
|
|
@ -4224,6 +4443,12 @@ async function saveReaderProgress(force = false) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Not restored yet (or another book): what's shown isn't a place read.
|
||||
const pos = _readPos;
|
||||
if (!pos || !pos.ready || pos.bookId !== currentBookId) return;
|
||||
// A jump that's only a look: the place before it stays.
|
||||
if (pos.hold && !force) return;
|
||||
|
||||
let anchor = '';
|
||||
if (!currentPdfDoc) {
|
||||
anchor = getPositionAnchor(contentEl);
|
||||
|
|
@ -4234,27 +4459,30 @@ async function saveReaderProgress(force = false) {
|
|||
}
|
||||
fraction = Math.min(1.0, Math.max(0.0, fraction));
|
||||
|
||||
_updateCachedBookProgress(currentBookId, fraction, anchor, force || (_repairProgressAnchor && !!anchor));
|
||||
|
||||
const repair = _repairProgressAnchor && !!anchor;
|
||||
if (repair) _repairProgressAnchor = false;
|
||||
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...((force || repair) && {force: true})});
|
||||
_lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body};
|
||||
|
||||
try {
|
||||
await fetch(`/books/${currentBookId}/progress/`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||
body,
|
||||
});
|
||||
_noteReadingPosition(anchor, fraction);
|
||||
if (force) {
|
||||
pos.readAt = Date.now();
|
||||
pos.moved = true;
|
||||
}
|
||||
// Nothing read here since opening: the server has this place already.
|
||||
if (!pos.moved) return;
|
||||
|
||||
const entry = {book: currentBookId, fraction, anchor, read_at: pos.readAt, device: _progressDevice(), ...(force && {force: true})};
|
||||
const sig = `${entry.book}|${anchor}|${fraction.toFixed(5)}|${entry.read_at}`;
|
||||
_lastProgressBeacon = entry;
|
||||
if (!force && sig === pos.sentSig) return; // unchanged since the last save
|
||||
pos.sentSig = sig;
|
||||
_progressOutboxPut(entry);
|
||||
_updateCachedBookProgress(currentBookId, fraction, anchor, force, pos.readAt);
|
||||
const sent = await _sendProgress(entry);
|
||||
if (!sent) pos.sentSig = null; // try again with the next save
|
||||
if (sent && force) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'reader-toast';
|
||||
toast.textContent = 'Position gesetzt';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 1800);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function closeReader() {
|
||||
|
|
@ -4262,6 +4490,7 @@ function closeReader() {
|
|||
stopReadAloud();
|
||||
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
||||
saveReaderProgress();
|
||||
_readPos = null; // nothing more is saved for this book
|
||||
if (bookmarksDirty) saveBookmarks();
|
||||
if (highlightsDirty) saveHighlights();
|
||||
|
||||
|
|
@ -6410,12 +6639,24 @@ function openRadioSidebar() {
|
|||
window.addEventListener('online', async () => {
|
||||
showOfflineBanner(false);
|
||||
await _syncPendingAnnotations();
|
||||
loadBookList(); // loadBookList pushes any locally-advanced progress automatically
|
||||
loadBookList(); // sends the positions read offline first (the progress outbox)
|
||||
});
|
||||
|
||||
// Show banner immediately if already offline at startup
|
||||
if (!navigator.onLine) showOfflineBanner(true);
|
||||
|
||||
// Reading position: positions reached by the reader's own doing count (see _noteReadingPosition).
|
||||
{
|
||||
const content = $('reader-content');
|
||||
if (content) {
|
||||
for (const type of ['wheel', 'touchstart', 'mousedown']) {
|
||||
content.addEventListener(type, _noteReaderInteraction, {passive: true, capture: true});
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', () => { if (currentBookId) _noteReaderInteraction(); }, true);
|
||||
if (IS_AUTHENTICATED) _flushProgressOutbox();
|
||||
}
|
||||
|
||||
// Footnote hover (desktop) + tap (mobile) via event delegation
|
||||
const _readerContentEl = $('reader-content');
|
||||
if (_readerContentEl) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v99';
|
||||
const CACHE = 'diora-v100';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue