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