71 lines
4.6 KiB
Markdown
71 lines
4.6 KiB
Markdown
# 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/`
|
||
|
||
```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".
|