diora-web/docs/progress-sync.md

4.6 KiB
Raw Permalink Blame History

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/46-readpos.js, with the reader in 62-reader.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/

{"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".