From b10bf3e8c761164c48d30bebe0348e15573fb5f3 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 26 Sep 2026 21:27:44 -0400 Subject: [PATCH] docs(api): real progress endpoint contract; remove nonexistent-endpoint docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/developer/api-reference.md | 148 +++++------------- docs/developer/api/api-reference.md | 10 +- docs/developer/api/koreader/get_metadata.md | 67 +++++--- docs/developer/api/koreader/sync_progress.md | 92 +++++++---- .../api/progress/delete_media_progress.md | 35 +++++ .../developer/api/progress/delete_progress.md | 36 ----- .../api/progress/get_media_progress.md | 98 ++++++++++++ docs/developer/api/progress/get_progress.md | 52 ------ .../api/progress/get_universal_progress.md | 50 ------ .../api/progress/position-contract.md | 135 ++++++++++++++++ .../api/progress/update_media_progress.md | 91 +++++++++++ .../developer/api/progress/update_progress.md | 68 -------- .../api/progress/update_universal_progress.md | 56 ------- 13 files changed, 506 insertions(+), 432 deletions(-) create mode 100644 docs/developer/api/progress/delete_media_progress.md delete mode 100644 docs/developer/api/progress/delete_progress.md create mode 100644 docs/developer/api/progress/get_media_progress.md delete mode 100644 docs/developer/api/progress/get_progress.md delete mode 100644 docs/developer/api/progress/get_universal_progress.md create mode 100644 docs/developer/api/progress/position-contract.md create mode 100644 docs/developer/api/progress/update_media_progress.md delete mode 100644 docs/developer/api/progress/update_progress.md delete mode 100644 docs/developer/api/progress/update_universal_progress.md diff --git a/docs/developer/api-reference.md b/docs/developer/api-reference.md index cb50d2d..86e5262 100644 --- a/docs/developer/api-reference.md +++ b/docs/developer/api-reference.md @@ -394,6 +394,11 @@ Authorization: Bearer ## 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 ```http @@ -405,57 +410,56 @@ Authorization: Bearer ```json { + "id": "uuid", "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", + "percentage": 0.045, + "epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)", + "context_text": "from day to day, but there was none in which ...", + "character_offset": 16375, + "current_page": null, + "total_pages": null, + "chapter": null, + "chapter_progress": null, "format_group": "reflowable", - "viewport_y": 0.12, - "zoom_level": 1.0 + "total_characters": 592216, + "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 ```http PUT /api/media-items/{media_id}/progress Authorization: Bearer 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 { - "sync_status": "success", - "progress_updated": true, - "devices_notified": ["device-1", "device-2"], - "broadcast": true + "percentage": 0.0415, + "context_text": "was at war with one of these Powers it was generally a", + "epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)" } ``` +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 ```http @@ -1297,82 +1301,10 @@ Authorization: Bearer ## Universal Progress -### Get Universal Progress - -```http -GET /api/progress/{book_uuid} -Authorization: Bearer -``` - -**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 -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": "..." - } -} -``` +Universal progress is the media-item progress — one row per (user, +media item) shared by every device. See [Reading Progress](#reading-progress) +and the [Position Contract](api/progress/position-contract.md). There +are no separate `/api/progress/:id` GET/POST endpoints. ## Conflicts diff --git a/docs/developer/api/api-reference.md b/docs/developer/api/api-reference.md index 134b423..335733f 100644 --- a/docs/developer/api/api-reference.md +++ b/docs/developer/api/api-reference.md @@ -117,10 +117,14 @@ See [Media Item Operations](media-items/) ## 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 -- POST /api/progress/:id - Update universal progress +- GET /api/media-items/:id/progress - Get reading progress + restore handles +- 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 ## Notes & Highlights diff --git a/docs/developer/api/koreader/get_metadata.md b/docs/developer/api/koreader/get_metadata.md index c09faba..483c0d3 100644 --- a/docs/developer/api/koreader/get_metadata.md +++ b/docs/developer/api/koreader/get_metadata.md @@ -1,9 +1,12 @@ # 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` -**Auth**: Required (Device authentication) +**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`) ## Path Parameters @@ -11,41 +14,55 @@ Get metadata for a book from KOReader device. | --------- | ------------- | -------- | ----------- | | 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 ```http -GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000 -X-Device-ID: 550e8400-e29b-41d4-a716-446655440000 -X-Device-Key: device-auth-key +GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56 +Authorization: Bearer {device_token} ``` ## Response (200 OK) ```json { - "id": "uuid", - "title": "Book Title", - "authors": ["Author Name"], - "path": "/path/to/book.epub", - "file_size": 1234567, - "modified_at": "2026-02-08T10:00:00Z" + "uuid": "774641f9-317b-4087-8e04-53bb4392ae56", + "title": "1984", + "author": "George Orwell", + "progress": { + "percentage": 0.045, + "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 -| Code | Description | -| ---- | ---------------------------- | +| Code | Description | +| ---- | ----------- | +| 400 | Invalid book UUID | | 401 | Device authentication failed | -| 404 | Book or device not found | +| 404 | Book not found | diff --git a/docs/developer/api/koreader/sync_progress.md b/docs/developer/api/koreader/sync_progress.md index dd45f51..4279928 100644 --- a/docs/developer/api/koreader/sync_progress.md +++ b/docs/developer/api/koreader/sync_progress.md @@ -1,63 +1,87 @@ # Sync Progress -Sync reading progress from KOReader device. +Push reading progress from a KOReader device. **Endpoint**: `POST /api/sync/koreader/progress` -**Auth**: Required (Device authentication) +**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`) -## Device Authentication - -This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials. +This is the device-native tier of the [Position +Contract](../progress/position-contract.md): the KOReader payload carries +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 -| Field | Type | Required | Description | -| --------- | ------------- | -------- | ------------------------- | -| device_id | string (UUID) | Yes | Device UUID | -| progress | array | Yes | Array of progress objects | +| Field | Type | Required | Description | +| --------- | ------ | -------- | ----------- | +| `books` | array | Yes | One book object (the reference client sends a single-element array). | +| `sync_mode`| string | No | `immediate` (default) or `manual`. | -### Progress Object +### Book Object -| Field | Type | Required | Description | -| ----------- | ------- | -------- | ---------------------------------- | -| book | string | Yes | Book identifier (filename or UUID) | -| percent | float | Yes | Progress percentage (0-100) | -| page | integer | No | Current page number | -| total_pages | integer | No | Total pages in document | -| date_read | string | No | ISO 8601 timestamp of last read | -| updated_at | string | Yes | ISO 8601 timestamp | +| Field | Type | Required | Description | +| ----- | ---- | -------- | ----------- | +| `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). | +| `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. | +| `title` | string | No | Document title. | +| `authors` | array | No | Author names. | +| `percentage` | float | Yes | Position as a fraction of the book (0..1). | +| `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 +```http +POST /api/sync/koreader/progress +Authorization: Bearer {device_token} +Content-Type: application/json +``` + ```json { - "device_id": "550e8400-e29b-41d4-a716-446655440000", - "progress": [ + "books": [ { - "book": "book.epub", - "percent": 75.5, - "page": 150, - "total_pages": 200, - "date_read": "2026-02-08T10:00:00Z", - "updated_at": "2026-02-08T10:00:00Z" + "uuid": "774641f9-317b-4087-8e04-53bb4392ae56", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "title": "1984", + "authors": ["George Orwell"], + "percentage": 0.045, + "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 { - "message": "Progress synced successfully", - "synced_count": 1 + "sync_status": "ok", + "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 -| Code | Description | -| ---- | ---------------------------- | +| Code | Description | +| ---- | ----------- | +| 400 | Invalid request format | | 401 | Device authentication failed | -| 400 | Invalid request data | -| 404 | Device not found | +| 500 | Database error | diff --git a/docs/developer/api/progress/delete_media_progress.md b/docs/developer/api/progress/delete_media_progress.md new file mode 100644 index 0000000..716e73f --- /dev/null +++ b/docs/developer/api/progress/delete_media_progress.md @@ -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 | diff --git a/docs/developer/api/progress/delete_progress.md b/docs/developer/api/progress/delete_progress.md deleted file mode 100644 index 83e6937..0000000 --- a/docs/developer/api/progress/delete_progress.md +++ /dev/null @@ -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 | diff --git a/docs/developer/api/progress/get_media_progress.md b/docs/developer/api/progress/get_media_progress.md new file mode 100644 index 0000000..02853b3 --- /dev/null +++ b/docs/developer/api/progress/get_media_progress.md @@ -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 | diff --git a/docs/developer/api/progress/get_progress.md b/docs/developer/api/progress/get_progress.md deleted file mode 100644 index 01f2f6f..0000000 --- a/docs/developer/api/progress/get_progress.md +++ /dev/null @@ -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 | diff --git a/docs/developer/api/progress/get_universal_progress.md b/docs/developer/api/progress/get_universal_progress.md deleted file mode 100644 index fbd2f6c..0000000 --- a/docs/developer/api/progress/get_universal_progress.md +++ /dev/null @@ -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 | diff --git a/docs/developer/api/progress/position-contract.md b/docs/developer/api/progress/position-contract.md new file mode 100644 index 0000000..8b15210 --- /dev/null +++ b/docs/developer/api/progress/position-contract.md @@ -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. diff --git a/docs/developer/api/progress/update_media_progress.md b/docs/developer/api/progress/update_media_progress.md new file mode 100644 index 0000000..fbd852d --- /dev/null +++ b/docs/developer/api/progress/update_media_progress.md @@ -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 | diff --git a/docs/developer/api/progress/update_progress.md b/docs/developer/api/progress/update_progress.md deleted file mode 100644 index 73ec679..0000000 --- a/docs/developer/api/progress/update_progress.md +++ /dev/null @@ -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 | diff --git a/docs/developer/api/progress/update_universal_progress.md b/docs/developer/api/progress/update_universal_progress.md deleted file mode 100644 index 2e91424..0000000 --- a/docs/developer/api/progress/update_universal_progress.md +++ /dev/null @@ -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 |