docs(api): real progress endpoint contract; remove nonexistent-endpoint docs

The progress documentation described GET/POST /api/progress/:id —
endpoints that do not exist in the router — while the endpoint every
client actually uses (GET/PUT/DELETE /api/media-items/:id/progress) had
no field-level docs at all.

New:
- progress/position-contract.md: the canonical position model — server
  as position authority, the three-tier submission (percentage /
  context_text / epubcfi), ingest verification and healing, the restore
  handles, the OPF spine numbering hazard (canonical CFI spine steps
  include linear="no" items; clients resolve documents by anchor_href
  and land by css_selector + char_offset, never by spine step), the
  offset currencies (UTF-16 at the wire, runes internal), and
  context_text rules.
- progress/get_media_progress.md and update_media_progress.md: the real
  endpoints with full field tables, conditionality of the restore
  handles, the first-page anti-clobber guard, and healed-response
  semantics.
- progress/delete_media_progress.md: the real DELETE route.
- koreader/sync_progress.md and koreader/get_metadata.md rewritten to
  the actual payloads: the plugin sends a single-book array whose
  "epubcfi" field is a CRE xpointer; the metadata response navigates
  via koreader_xpointer (canonical CFI converted back to CRE), with
  page as the canonical locator for fixed-layout books.

Removed: the five files documenting the nonexistent /api/progress/:id
GET/POST/DELETE endpoints. Kept get_progress_history.md (that route
exists). The legacy developer/api-reference.md and the indexed
api/api-reference.md progress sections now match the wire and link the
new docs; the duplicate "Universal Progress" section points at Reading
Progress.
This commit is contained in:
John O'Keefe
2026-09-26 21:27:44 -04:00
parent 8c3273a0fc
commit b10bf3e8c7
13 changed files with 506 additions and 432 deletions
+40 -108
View File
@@ -394,6 +394,11 @@ Authorization: Bearer <token>
## Reading Progress ## Reading Progress
Full field reference: [api/progress/](api/progress/) — and read the
[Position Contract](api/progress/position-contract.md) (verification,
healing, the OPF spine numbering hazard, offset currencies) before
writing a client.
### Get Reading Progress ### Get Reading Progress
```http ```http
@@ -405,57 +410,56 @@ Authorization: Bearer <token>
```json ```json
{ {
"id": "uuid",
"media_item_id": "uuid", "media_item_id": "uuid",
"user_id": "uuid", "user_id": "uuid",
"current_page": 45, "percentage": 0.045,
"total_pages": 200, "epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
"percentage": 0.225, "context_text": "from day to day, but there was none in which ...",
"character_offset": 15432, "character_offset": 16375,
"epubcfi": "epubcfi(/6/4/2:15)", "current_page": null,
"chapter": 3, "total_pages": null,
"chapter_progress": 0.5, "chapter": null,
"last_read_at": "2026-01-31T10:00:00Z", "chapter_progress": null,
"format_group": "reflowable", "format_group": "reflowable",
"viewport_y": 0.12, "total_characters": 592216,
"zoom_level": 1.0 "chapter_count": 1,
"last_read_at": "2026-09-26T14:52:34Z",
"last_sync_source": "koreader",
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
"anchor_href": "1984.xhtml",
"char_offset": 598
} }
``` ```
`css_selector`, `anchor_href` and `char_offset` are server-derived
restore handles, served for convertible reflowable books with a
resolvable anchor. `char_offset` is UTF-16 code units within the anchor
block's text; `character_offset` is a book-wide rune count. Resolve the
document by `anchor_href`, never by the CFI's spine step (OPF numbering
includes `linear="no"` items).
### Update Reading Progress ### Update Reading Progress
```http ```http
PUT /api/media-items/{media_id}/progress PUT /api/media-items/{media_id}/progress
Authorization: Bearer <token> Authorization: Bearer <token>
Content-Type: application/json Content-Type: application/json
{
"source": "web",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "Mozilla/5.0..."
}
}
``` ```
**Response** (200):
```json ```json
{ {
"sync_status": "success", "percentage": 0.0415,
"progress_updated": true, "context_text": "was at war with one of these Powers it was generally a",
"devices_notified": ["device-1", "device-2"], "epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
"broadcast": true
} }
``` ```
All fields are optional. The server verifies the submission against the
book and heals it on mismatch; the response is the stored row after
verification. A `percentage` below 0.005 is ignored while stored
progress exceeds 0.01 (first-page anti-clobber).
### Delete Reading Progress ### Delete Reading Progress
```http ```http
@@ -1297,82 +1301,10 @@ Authorization: Bearer <device_token>
## Universal Progress ## Universal Progress
### Get Universal Progress Universal progress is the media-item progress — one row per (user,
media item) shared by every device. See [Reading Progress](#reading-progress)
```http and the [Position Contract](api/progress/position-contract.md). There
GET /api/progress/{book_uuid} are no separate `/api/progress/:id` GET/POST endpoints.
Authorization: Bearer <token>
```
**Response** (200):
```json
{
"book_id": "book-uuid",
"format_group": "reflowable",
"universal_progress": 0.45678,
"location_references": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"chapter_progress": 0.234,
"viewport_y": 0.12
},
"device_progress": {
"koreader": {
"percentage": 0.45678,
"last_sync": "2026-01-30T20:00:00Z"
},
"kobo": {
"percentage": 45.6,
"last_sync": "2026-01-30T19:55:00Z"
},
"web": {
"display_page": 89,
"total_pages": 200,
"last_sync": "2026-01-30T20:05:00Z"
}
},
"annotations": {
"highlights": [...],
"notes": [...],
"bookmarks": [...]
},
"conflicts": [
{
"id": "conflict-uuid",
"type": "progress",
"resolved": false,
"sources": ["koreader", "kobo"]
}
]
}
```
### Update Universal Progress
```http
POST /api/progress/{book_uuid}
Authorization: Bearer <token>
Content-Type: application/json
{
"source": "web|koreader|kobo|mobile",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "..."
}
}
```
## Conflicts ## Conflicts
+7 -3
View File
@@ -117,10 +117,14 @@ See [Media Item Operations](media-items/)
## Reading Progress ## Reading Progress
See [Progress Tracking](progress/) See [Progress Tracking](progress/) — in particular the
[Position Contract](progress/position-contract.md) (verification,
healing, the OPF spine numbering hazard, and offset currencies) before
writing a client.
- GET /api/progress/:id - Get universal progress - GET /api/media-items/:id/progress - Get reading progress + restore handles
- POST /api/progress/:id - Update universal progress - PUT /api/media-items/:id/progress - Submit reading progress (verified/healed server-side)
- DELETE /api/media-items/:id/progress - Delete reading progress
- GET /api/progress/:id/history - Get progress history - GET /api/progress/:id/history - Get progress history
## Notes & Highlights ## Notes & Highlights
+41 -24
View File
@@ -1,9 +1,12 @@
# Get Metadata # Get Metadata
Get metadata for a book from KOReader device. Get a book's stored progress and annotations for a KOReader device —
the pull half of the device sync. The reference client calls this after
linking a book via [Resolve Book](resolve_book.md) and navigates to the
returned position.
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid` **Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
**Auth**: Required (Device authentication) **Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
## Path Parameters ## Path Parameters
@@ -11,41 +14,55 @@ Get metadata for a book from KOReader device.
| --------- | ------------- | -------- | ----------- | | --------- | ------------- | -------- | ----------- |
| uuid | string (UUID) | Yes | Book UUID | | uuid | string (UUID) | Yes | Book UUID |
## Device Authentication
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
## Request Headers
| Header | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------- |
| X-Device-ID | string | Yes | Device UUID |
| X-Device-Key | string | Yes | Device authentication key |
### Example Request ### Example Request
```http ```http
GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000 GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56
X-Device-ID: 550e8400-e29b-41d4-a716-446655440000 Authorization: Bearer {device_token}
X-Device-Key: device-auth-key
``` ```
## Response (200 OK) ## Response (200 OK)
```json ```json
{ {
"id": "uuid", "uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
"title": "Book Title", "title": "1984",
"authors": ["Author Name"], "author": "George Orwell",
"path": "/path/to/book.epub", "progress": {
"file_size": 1234567, "percentage": 0.045,
"modified_at": "2026-02-08T10:00:00Z" "koreader_xpointer": "/body/DocFragment[1]/body/p[29]/text().598",
"chapter": null,
"chapter_progress": null,
"page": null,
"total_pages": null
},
"annotations": {
"highlights": []
}
} }
``` ```
### Progress Object
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Stored position as a book fraction. |
| `koreader_xpointer` | string | The stored canonical position converted back to a CRE xpointer (UTF-16 `text().N` offset). **The device should navigate to this.** Reflowable books only. |
| `epubcfi` | string | The stored canonical CFI, when the conversion to a CRE xpointer is unavailable. Fallback after `koreader_xpointer`. |
| `character` | int | Book-wide rune offset (internal currency). |
| `chapter`, `chapter_progress` | int, float | Chapter position when known. |
| `page`, `total_pages` | int | Fixed-layout page position — the canonical locator for image-based books (CFI/xpointer are omitted for them). |
`progress` is `null` when the book has no stored progress.
The `annotations` object carries device-format highlights/bookmarks/notes
synced from other clients; its presence depends on annotation sync being
enabled.
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
| ---- | ---------------------------- | | ---- | ----------- |
| 400 | Invalid book UUID |
| 401 | Device authentication failed | | 401 | Device authentication failed |
| 404 | Book or device not found | | 404 | Book not found |
+55 -31
View File
@@ -1,63 +1,87 @@
# Sync Progress # Sync Progress
Sync reading progress from KOReader device. Push reading progress from a KOReader device.
**Endpoint**: `POST /api/sync/koreader/progress` **Endpoint**: `POST /api/sync/koreader/progress`
**Auth**: Required (Device authentication) **Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
## Device Authentication This is the device-native tier of the [Position
Contract](../progress/position-contract.md): the KOReader payload carries
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials. a CRE xpointer and the server converts it to the canonical standard CFI,
verifies it against the submitted `context_text`, and heals it on
mismatch — exactly like every other client.
## Request Body ## Request Body
| Field | Type | Required | Description | | Field | Type | Required | Description |
| --------- | ------------- | -------- | ------------------------- | | --------- | ------ | -------- | ----------- |
| device_id | string (UUID) | Yes | Device UUID | | `books` | array | Yes | One book object (the reference client sends a single-element array). |
| progress | array | Yes | Array of progress objects | | `sync_mode`| string | No | `immediate` (default) or `manual`. |
### Progress Object ### Book Object
| Field | Type | Required | Description | | Field | Type | Required | Description |
| ----------- | ------- | -------- | ---------------------------------- | | ----- | ---- | -------- | ----------- |
| book | string | Yes | Book identifier (filename or UUID) | | `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). |
| percent | float | Yes | Progress percentage (0-100) | | `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. |
| page | integer | No | Current page number | | `title` | string | No | Document title. |
| total_pages | integer | No | Total pages in document | | `authors` | array | No | Author names. |
| date_read | string | No | ISO 8601 timestamp of last read | | `percentage` | float | Yes | Position as a fraction of the book (0..1). |
| updated_at | string | Yes | ISO 8601 timestamp | | `context_text` | string | No | Up to 100 whitespace-normalized chars from the current position — enables the server's verification/healing. Strongly recommended. |
| `page` | int | No | Current page (fixed-layout books). |
| `total_pages` | int | No | Page count (fixed-layout books). |
| `epubcfi` | string | Reflowable only | **A CRE xpointer** (`/body/DocFragment[N]/body/...`), not a CFI — the field name is historical. Fixed-layout books must omit it and carry their position in `page`/`total_pages`. |
| `file_path` | string | No | Device-local file path (informational). |
| `device_info` | object | No | `{ koreader_version, device_model }`. |
### Example Request ### Example Request
```http
POST /api/sync/koreader/progress
Authorization: Bearer {device_token}
Content-Type: application/json
```
```json ```json
{ {
"device_id": "550e8400-e29b-41d4-a716-446655440000", "books": [
"progress": [
{ {
"book": "book.epub", "uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
"percent": 75.5, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"page": 150, "title": "1984",
"total_pages": 200, "authors": ["George Orwell"],
"date_read": "2026-02-08T10:00:00Z", "percentage": 0.045,
"updated_at": "2026-02-08T10:00:00Z" "context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim",
"page": 14,
"total_pages": 311,
"epubcfi": "/body/DocFragment[1]/body/p[29]/text().598",
"device_info": {
"koreader_version": "v2026.07.1",
"device_model": "emulator"
} }
] }
],
"sync_mode": "immediate"
} }
``` ```
## Response (200 OK) ## Response (202 Accepted)
```json ```json
{ {
"message": "Progress synced successfully", "sync_status": "ok",
"synced_count": 1 "books_synced": 1,
"timestamp": "2026-09-26T21:25:09Z"
} }
``` ```
Per-book results and any detected sync conflicts are carried in
`book_results` and `conflicts` when present.
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
| ---- | ---------------------------- | | ---- | ----------- |
| 400 | Invalid request format |
| 401 | Device authentication failed | | 401 | Device authentication failed |
| 400 | Invalid request data | | 500 | Database error |
| 404 | Device not found |
@@ -0,0 +1,35 @@
# Delete Media Item Progress
Delete the stored reading progress for a media item.
**Endpoint**: `DELETE /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ---------------- |
| id | string (UUID) | Yes | Media item UUID |
### Example Request
```http
DELETE /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
## Response (200 OK)
```json
{
"message": "reading progress deleted"
}
```
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid media item id |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,36 +0,0 @@
# Delete Reading Progress
Delete reading progress for a media item.
**Endpoint**: `DELETE /api/media-items/{media_id}/progress`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
DELETE /api/media-items/uuid/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (204 No Content)
Progress deleted successfully.
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -0,0 +1,98 @@
# Get Media Item Progress
Get the stored reading progress for a media item, plus the
server-derived restore handles for reflowable books.
**Endpoint**: `GET /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
See [Position Contract](position-contract.md) for the semantics of every
field — what is verified, what the currencies are, and how clients
should restore.
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
### Example Request
```http
GET /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
## Response (200 OK) — no progress stored
When the item has no progress row, an empty fixed-layout-style stub is
returned (not 404):
```json
{
"current_page": 0,
"total_pages": null
}
```
## Response (200 OK) — progress stored
```json
{
"id": "e8239659-0ed6-42ae-a946-8440d7655b42",
"media_item_id": "774641f9-317b-4087-8e04-53bb4392ae56",
"user_id": "1b64992e-3408-4d84-9e03-e2dc4950e1dd",
"current_page": null,
"total_pages": null,
"last_read_at": "2026-09-26T14:52:34.806446Z",
"percentage": 0.045,
"character_offset": 16375,
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
"chapter": null,
"chapter_progress": null,
"format_group": "reflowable",
"total_characters": 592216,
"chapter_count": 1,
"last_sync_device": "web",
"last_sync_source": "koreader",
"last_sync_timestamp": "2026-09-26T14:52:34.806446Z",
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
"anchor_href": "1984.xhtml",
"char_offset": 598,
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim"
}
```
### Field Reference
Base fields (always present when a row exists):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Position as a fraction of the whole book (0..1). |
| `epubcfi` | string | The stored canonical standard CFI. **Spine steps index the OPF spine as written, including `linear="no"` items — do not resolve them against a readium reading order.** See the [Position Contract](position-contract.md#spine-numbering-hazard--read-this-before-parsing-a-stored-cfi). |
| `context_text` | string | The stored verification context (≤100 whitespace-normalized chars from the anchor). |
| `character_offset` | int | Book-wide rune offset. Internal currency — consistent with `total_characters`. |
| `current_page`, `total_pages` | int | Fixed-layout page position (null for reflowable). |
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction, when known. |
| `format_group` | string | `reflowable`, `fixed_layout`, `comic_archive`, … Gates the restore handles. |
| `total_characters`, `chapter_count` | int | Book metrics, for client-side fraction math. |
| `last_sync_device`, `last_sync_source`, `last_sync_timestamp` | — | Which client last wrote the row. |
Restore handles (conditional — served only for convertible reflowable
books whose stored anchor re-resolves at GET time):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `anchor_href` | string | The spine document containing the anchor (e.g. `1984.xhtml`). **Resolve your resource by this**, never by the CFI's spine step. |
| `css_selector` | string | Body-relative chain to the anchor's block element. |
| `char_offset` | int | Anchor offset within the block's concatenated text, in UTF-16 code units. |
| `epubcfi` | string | Re-served (possibly healed) canonical CFI — present whenever re-verification produced one. |
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid media item id |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,52 +0,0 @@
# Get Reading Progress
Retrieve reading progress for a specific media item.
**Endpoint**: `GET /api/media-items/{media_id}/progress`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
GET /api/media-items/uuid/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"user_id": "uuid",
"current_page": 45,
"total_pages": 200,
"percentage": 0.225,
"character_offset": 15432,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3,
"chapter_progress": 0.5,
"last_read_at": "2026-01-31T10:00:00Z",
"format_group": "reflowable",
"viewport_y": 0.12,
"zoom_level": 1.0
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -1,50 +0,0 @@
# Get Universal Progress
Get universal (device-agnostic) reading progress for a media item.
**Endpoint**: `GET /api/progress/:id`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
GET /api/progress/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"percentage": 75.5,
"position": 1234,
"page": 150,
"total_pages": 200,
"finished": false,
"updated_at": "2026-02-08T10:00:00Z",
"device": {
"id": "device-uuid",
"name": "My Kobo"
}
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -0,0 +1,135 @@
# Position Contract
How reading positions are represented, submitted, verified, and restored
across clients. This is the contract every client speaks — readium-based
apps, web (foliate), KOReader, and Kobo devices alike.
**The server is the position authority.** Every submission is
independently verified against the book itself before it is stored; a
client whose locator math is wrong cannot poison the stored position.
In exchange, the server hands back handles clients can apply directly,
so they never have to parse or trust CFIs themselves.
## The three-tier submission
Clients submit what their renderer can reliably observe. All three tiers
are accepted on `PUT /api/media-items/:id/progress` (and on the KOReader
device endpoints):
| Tier | Field | Currency | Notes |
| ------ | -------------- | ----------------------------------------------------- | ----- |
| 1 | `percentage` | Float 0..1 of the whole book | Universal. The only field every client can supply. |
| 2 | `context_text` | Up to 100 chars, whitespace-normalized, starting at the anchor | The verification anchor. The server extracts the text at the submitted structural anchor and compares. |
| 3 | `epubcfi` | Standard wrapped CFI, terminals in UTF-16 code units | Optional structural anchor. KOReader submits a CRE xpointer and the server converts it. |
Tier 2 is what makes the system self-correcting: percentages alone
cannot distinguish "the reader's locator is right" from "the reader
silently reported wherever it is currently scrolled".
## Verification and healing (ingest side)
On every progress save for a convertible reflowable book that carries a
`context_text`, the server:
1. Parses the submitted `epubcfi` (if any) and resolves it against the
book's own XHTML.
2. Extracts the text at the resolved anchor and cross-checks it with the
submitted `context_text`.
3. On a mismatch — or an unresolvable anchor, or no anchor at all —
heals the position by text search, using the submitted `percentage`
to disambiguate repeated phrases. The healed CFI and a recomputed
percentage are stored in place of the submitted ones.
4. Refreshes the book-wide `character_offset` column from the verified
anchor on every verified save, so it never goes stale behind the
anchor.
If verification fails outright (book file unreadable, context not found
and percentage cannot disambiguate), the error is logged and the
submission is stored as-is — verification never rejects a save, it only
corrects.
## The restore handles
`GET /api/media-items/:id/progress` serves, alongside the raw stored
fields, the server-derived handles for reflowable books with a
resolvable anchor:
| Field | Currency | Meaning |
| ------------- | ----------------------- | ------- |
| `anchor_href` | — | The spine document the anchor lives in (e.g. `1984.xhtml`). **This is how a client finds the right resource.** |
| `css_selector`| — | Body-relative `tag:nth-child(k)` chain of the anchor's block element (e.g. `body>div:nth-child(4)>p:nth-child(29)`). |
| `char_offset` | UTF-16 code units | Offset of the anchor within the concatenated text of that block. |
| `epubcfi` | UTF-16 terminals | The stored canonical CFI — re-served healed if the GET-time re-verification improved it. |
| `context_text`| — | The stored verification context (≤100 normalized chars from the anchor). |
**Recommended restore sequence for a client:**
1. Open the book and jump to the stored `percentage` (coarse floor).
2. Resolve `anchor_href` against your own spine (an ends-with match on
document hrefs) and open that document if you are not already there.
3. Query `css_selector` in that document, walk its text nodes counting
UTF-16 units to `char_offset`, and scroll that position into view.
4. If the anchor cannot be measured (renderer-specific laziness), the
percentage floor stands.
## SPINE NUMBERING HAZARD — read this before parsing a stored CFI
**`epubcfi` spine steps index the OPF spine AS WRITTEN, including
`linear="no"` items.** Several rendering engines (notably readium)
number their reading order EXCLUDING `linear="no"` items. When a book's
cover (or any other item) is `linear="no"`, the two numberings differ by
a constant offset from that item onward — a client resolving a stored
CFI's spine step against its own numbering lands in the WRONG DOCUMENT.
This is not hypothetical: "1984" epubs commonly have a `linear="no"`
cover, which makes readium spine 0 = OPF spine 1. The server heals such
numbering mismatches at ingest (that is what the context check is for),
but the durable rule for client authors is:
> **Never resolve a stored CFI's spine step yourself.** Resolve the
> document by `anchor_href`, then land with `css_selector` +
> `char_offset`. Treat the CFI as opaque server currency.
## Offset currencies
Two counting systems are in play, and they are deliberately kept apart:
| Quantity | Currency | Why |
| -------- | -------- | --- |
| CFI terminal offsets (`…/1:456`) | UTF-16 code units | The EPUB CFI spec, and what every client observes (JavaScript `.length`). |
| `char_offset` (block-relative handle) | UTF-16 code units | Same reason — clients walk DOM text with JS semantics. |
| KOReader CRE `text().N` offsets | UTF-16 code units | crengine is UCS-16 internally. |
| `character_offset` (book-wide column) | Unicode runes | Internal, consistent with `total_characters` and the percentage derivations. |
For all-BMP text the two currencies are identical. They diverge on
astral-plane characters (emoji, rare CJK ideographs): one rune, two
UTF-16 units. The server converts at every wire boundary; internal
arithmetic never crosses.
## `context_text` rules
- Starts at the anchor position (it may begin mid-word).
- Whitespace-normalized (all runs of whitespace collapse to single
spaces).
- At most 100 characters.
- Comparison is containment-based (client and server suffixes of the
same block verify in either direction); contexts shorter than 12
chars never match.
## Engine-specific notes
- **readium-based clients** (the Android app): submit
percentage + `context_text` + a CFI generated from the laid-out
WebView. Their locally-generated CFI spine steps use readium
numbering — the server heals the difference; nothing to do.
- **Web (foliate)**: submit all three tiers; foliate CFIs are the same
currency the server stores.
- **KOReader**: submits a CRE xpointer in the `epubcfi` field of the
device payload (historical field name; it is a CRE xpointer, not a
CFI). The server converts CRE → canonical at ingest and canonical →
CRE on pull (`koreader_xpointer` in the metadata response).
- **Kobo**: submits kepub CFI locators, converted server-side the same
way.
- **Fixed-layout content** (PDF/CBZ): the page index is the canonical
locator; CFI/xpointer are meaningless and neither submitted nor
served.
@@ -0,0 +1,91 @@
# Update Media Item Progress
Submit reading progress for a media item. This is the position-authority
ingest point: the server independently verifies the submission against
the book and heals it when the client's projection is wrong.
**Endpoint**: `PUT /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
**Content-Type**: `application/json`
See the [Position Contract](position-contract.md) for the three-tier
submission model, the verification/healing semantics, and the offset
currencies.
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ---------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Body
All fields are optional; submit what your renderer can observe.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Position as a fraction of the whole book (0..1). Tier 1 — the universal field. |
| `context_text` | string | Up to 100 whitespace-normalized chars starting at the anchor. Tier 2 — enables verification and healing. |
| `epubcfi` | string | Standard wrapped CFI (UTF-16 terminals). Tier 3 — the structural anchor. Note: the server re-derives the stored canonical CFI; a client's own spine numbering is healed if it disagrees with the OPF spine. |
| `character_offset` | int | Book-wide rune offset. Accepted but recomputed server-side from the verified anchor on every verified save. |
| `current_page`, `total_pages` | int | Fixed-layout position. For fixed-layout formats the page index is the canonical locator. |
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction. |
| `reading_mode` | string | `paged` or `scrolled` (fixed-layout reader state). |
| `zoom_level`, `scroll_position_x`, `scroll_position_y` | float | Fixed-layout viewport state. |
### Example Request (reflowable, all three tiers)
```http
PUT /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
```
```json
{
"percentage": 0.0415,
"context_text": "was at war with one of these Powers it was generally at peace with the other. But what was strange w",
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
}
```
### Example Request (fixed-layout)
```json
{
"percentage": 0.15,
"current_page": 30,
"total_pages": 194,
"reading_mode": "paged"
}
```
## Behavior
- The submission is verified against the book's XHTML when
`context_text` is present and the book is convertible reflowable: the
CFI is resolved, the text at the anchor is compared with
`context_text`, and mismatches heal by text search (percentage
disambiguates repeats). See [Position Contract](position-contract.md).
- **Anti-clobber guard**: a submission with `percentage < 0.005` is
ignored with `{"status": "ignored"}` when the stored row already holds
a percentage above `0.01` — re-opening a book at its first page does
not wipe real progress.
- The response is the stored row after verification, including the
healed `epubcfi` and the refreshed `character_offset` when
verification ran.
## Response (200 OK)
The saved progress row (same shape as
[GET](get_media_progress.md), minus the GET-time handles). The
`epubcfi` and `percentage` in the response are the server-verified
values, which may differ from the submitted ones when healing occurred.
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid request data |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,68 +0,0 @@
# Update Reading Progress
Update reading progress for a media item. This will sync across all devices via WebSocket.
**Endpoint**: `PUT /api/media-items/{media_id}/progress`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Body
| Field | Type | Required | Description |
| --------------------------- | ------- | -------- | ------------------------------------------------- |
| source | string | Yes | Progress source (e.g., "web", "koreader", "kobo") |
| location | object | Yes | Location information |
| location.percentage | float | No | Progress percentage (0-1) |
| location.epubcfi | string | No | EPUB CFI location |
| location.character | integer | No | Character offset |
| location.chapter | integer | No | Chapter number |
| location.page | integer | No | Current page |
| location.total_pages | integer | No | Total pages |
| device_metadata | object | No | Device metadata |
| device_metadata.device_type | string | No | Device type |
| device_metadata.user_agent | string | No | User agent string |
### Example Request
```json
{
"source": "web",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "Mozilla/5.0..."
}
}
```
## Response (200 OK)
```json
{
"sync_status": "success",
"progress_updated": true,
"devices_notified": ["device-1", "device-2"],
"broadcast": true
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 400 | Invalid location data |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -1,56 +0,0 @@
# Update Universal Progress
Update universal reading progress for a media item.
**Endpoint**: `POST /api/progress/:id`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Body
| Field | Type | Required | Description |
| ---------- | ------------- | -------- | ------------------------------------------- |
| percentage | float | No | Progress percentage (0-100) |
| position | integer | No | Current position in bytes |
| page | integer | No | Current page number |
| finished | boolean | No | Whether the book is finished |
| device_id | string (UUID) | No | Device UUID (optional, for tracking source) |
### Example Request
```json
{
"percentage": 75.5,
"position": 1234,
"page": 150,
"finished": false,
"device_id": "device-uuid"
}
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"percentage": 75.5,
"position": 1234,
"page": 150,
"finished": false,
"updated_at": "2026-02-08T10:00:00Z"
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 400 | Invalid request data |
| 401 | Invalid or expired token |
| 404 | Media item not found |