diff --git a/accounts/sync.py b/accounts/sync.py index 5dccfb3..38a17d6 100644 --- a/accounts/sync.py +++ b/accounts/sync.py @@ -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, diff --git a/accounts/tests.py b/accounts/tests.py index 8eb3201..6a068b8 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -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): diff --git a/books/migrations/0005_progress_last_read_wins.py b/books/migrations/0005_progress_last_read_wins.py new file mode 100644 index 0000000..baf8ff9 --- /dev/null +++ b/books/migrations/0005_progress_last_read_wins.py @@ -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), + ] diff --git a/books/models.py b/books/models.py index 9bc06c7..ee1a91e 100644 --- a/books/models.py +++ b/books/models.py @@ -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: diff --git a/books/tests.py b/books/tests.py new file mode 100644 index 0000000..93d9ac4 --- /dev/null +++ b/books/tests.py @@ -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, '')) diff --git a/books/views.py b/books/views.py index 121a604..41865ad 100644 --- a/books/views.py +++ b/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): - 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 diff --git a/docs/progress-sync.md b/docs/progress-sync.md new file mode 100644 index 0000000..1cefc30 --- /dev/null +++ b/docs/progress-sync.md @@ -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//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 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". diff --git a/static/js/app.js b/static/js/app.js index a094a04..e7ac60e 100644 --- a/static/js/app.js +++ b/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 = `Weit gescrollt`; 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 = ''; + 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 = '

Bücherliste wird vom Server geholt …

'; + _flushProgressOutbox(); // own positions the server doesn't have yet const res = await fetch('/books/', {cache: 'no-store'}); + _noteServerNowHeader(res); if (!res.ok) { listEl.innerHTML = `

Serverfehler ${res.status} beim Laden der Bücher.

`; 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 . 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) { diff --git a/static/js/sw.js b/static/js/sw.js index 4d369e7..97ced2b 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -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',