Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m21s

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:
mrwnslz 2026-10-12 19:31:36 +02:00
parent fda6c01943
commit 60dd4926cf
9 changed files with 693 additions and 111 deletions

View file

@ -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,

View file

@ -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):

View 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),
]

View file

@ -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
View 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, ''))

View file

@ -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):
progress.scroll_fraction = scroll_fraction
progress.position_anchor = position_anchor
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
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.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
View 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".

View file

@ -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);
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
anchor = bookData ? (bookData.position_anchor || '') : '';
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));
_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 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,
});
if (force) {
const toast = document.createElement('div');
toast.className = 'reader-toast';
toast.textContent = 'Position gesetzt';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 1800);
}
} catch (e) {}
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);
}
}
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) {

View file

@ -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',