From 6f13dc2b63e4b69367612d01cf995cfa22ea690b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 24 Oct 2026 16:09:38 -0400 Subject: [PATCH] =?UTF-8?q?docs(media-items=20core):=20Beam=20WO5=20batch?= =?UTF-8?q?=204=20=E2=80=94=2012=20endpoints=20backfilled,=20registry=20pi?= =?UTF-8?q?nned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beam backfilled list/get/create/update/delete media-item, search, ratings CRUD, progress GET/PUT. Beam correctly identified create/update/delete as jwt+admin (admin group in the router — verified). Claude registry: 12 entries. Notable pins: - get_media_item: hand-built ~44-key map pinned verbatim from the handler - create/update return the full database.MediaItems row (74 columns) — examples merged up to the real shape via gate-driven merge script - search returns []SearchMediaItemsRow (sqlc row — Real type pinned) - rating scale is 1-10 int (odd = half-star); GET returns {rating: null} when absent; update reuses the create handler (201) - progress GET: 25 base keys + 4 CONDITIONAL position-authority keys (css_selector/anchor_href/char_offset/context_text — added by the handler for convertible formats with a valid CFI); PUT progress binds sync.SaveProgressRequest and returns database.ReadingProgress - Beam's examples were missing entirely on 4 files (list/search/progress GET/PUT) — relabeled existing fences or inserted contract-shaped blocks Gate-driven merge scripts (object + array variants) brought all examples to the exact handler key sets. make test 9/9. --- .../api/media-items/create_media_item.md | 92 +++++++++++++++++-- .../api/media-items/delete_media_item.md | 8 ++ .../api/media-items/get_media_item.md | 54 +++++++++-- .../api/media-items/list_media_items.md | 10 +- .../api/media-items/search_media_items.md | 89 +++++++++++++++++- .../api/media-items/update_media_item.md | 81 +++++++++++++++- .../api/progress/get_media_progress.md | 20 +++- .../api/progress/update_media_progress.md | 40 +++++++- docs/developer/api/ratings/create_rating.md | 16 +++- docs/developer/api/ratings/delete_rating.md | 10 +- docs/developer/api/ratings/get_ratings.md | 15 ++- docs/developer/api/ratings/update_rating.md | 13 ++- internal/docs/schema.go | 72 +++++++++++++++ 13 files changed, 486 insertions(+), 34 deletions(-) diff --git a/docs/developer/api/media-items/create_media_item.md b/docs/developer/api/media-items/create_media_item.md index 46c67e73..f34ee5aa 100644 --- a/docs/developer/api/media-items/create_media_item.md +++ b/docs/developer/api/media-items/create_media_item.md @@ -1,3 +1,11 @@ +--- +method: POST +path: /api/media-items +auth: jwt+admin +area: media-items +--- + + # Create Media Item Create a new media item (Admin only). @@ -47,7 +55,7 @@ Tags and contributors are automatically normalized: ### Example Request -```json +```json example-request { "library_id": "550e8400-e29b-41d4-a716-446655440000", "title": "Example Book Title", @@ -67,7 +75,7 @@ Tags and contributors are automatically normalized: ## Response (201 Created) -```json +```json example-response { "id": "550e8400-e29b-41d4-a716-446655440001", "library_id": "550e8400-e29b-41d4-a716-446655440000", @@ -80,13 +88,83 @@ Tags and contributors are automatically normalized: "mime_type": "application/epub+zip", "series": "Tech Series", "series_number": 1, - "tags": ["Science-Fiction", "Technology", "ACME CORP."], - "tags_search": ["science fiction", "technology", "acme corp"], + "tags": [ + "Science-Fiction", + "Technology", + "ACME CORP." + ], + "tags_search": [ + "science fiction", + "technology", + "acme corp" + ], "publisher": "O'Reilly Media", - "contributors": ["John Smith", "ACME CORP."], - "contributors_search": ["john smith", "acme corp"], + "contributors": [ + "John Smith", + "ACME CORP." + ], + "contributors_search": [ + "john smith", + "acme corp" + ], "created_at": "2026-01-31T10:00:00Z", - "updated_at": "2026-01-31T10:00:00Z" + "updated_at": "2026-01-31T10:00:00Z", + "abridged": null, + "added_by_admin_id": null, + "age_rating": null, + "alternate_info": null, + "archived_at": null, + "asin": null, + "audio_metadata": null, + "authors": null, + "authors_search": null, + "chapter_count": null, + "chapter_metadata": null, + "community_rating": null, + "copyright_year": null, + "cover_image_path": null, + "date_published": null, + "edition": null, + "entitlement_id": null, + "explicit": null, + "file_sha256": null, + "format_group": null, + "format_mimetype": null, + "genre": null, + "goodreads_id": null, + "google_books_id": null, + "has_fixed_layout": null, + "hash_confidence": null, + "imported_at": null, + "imprint": null, + "is_black_and_white": null, + "is_reflowable": null, + "kobo_content_id": null, + "kobo_metadata": null, + "language": null, + "library_type_name": null, + "manga_type": null, + "metadata_notes": null, + "metadata_overrides": null, + "missing_scan_count": null, + "narrators": null, + "narrators_search": null, + "openlibrary_id": null, + "opf_identifier": null, + "opf_uuid": null, + "page_count": null, + "publishers": null, + "publishers_search": null, + "reading_direction": null, + "revision_number": null, + "scan_information": null, + "series_count": null, + "story_arc": null, + "subtitle": null, + "summary": null, + "total_characters": null, + "volume": null, + "web_url": null } ``` diff --git a/docs/developer/api/media-items/delete_media_item.md b/docs/developer/api/media-items/delete_media_item.md index 01de1550..911efa10 100644 --- a/docs/developer/api/media-items/delete_media_item.md +++ b/docs/developer/api/media-items/delete_media_item.md @@ -1,3 +1,11 @@ +--- +method: DELETE +path: /api/media-items/:id +auth: jwt+admin +area: media-items +--- + + # Delete Media Item Delete a media item from the library (Admin only). diff --git a/docs/developer/api/media-items/get_media_item.md b/docs/developer/api/media-items/get_media_item.md index 2afcd710..d8c8b1c1 100644 --- a/docs/developer/api/media-items/get_media_item.md +++ b/docs/developer/api/media-items/get_media_item.md @@ -1,3 +1,11 @@ +--- +method: GET +path: /api/media-items/:id +auth: jwt +area: media-items +--- + + # Get Media Item Retrieve details of a specific media item. @@ -26,7 +34,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ## Response (200 OK) -```json +```json example-response { "id": "uuid", "library_id": "uuid", @@ -39,15 +47,47 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... "cover_image_path": "/path/to/cover.jpg", "series": "Series Name", "series_number": 1, - "tags": ["sci-fi", "space opera"], - "tags_search": ["sci fi", "space opera"], - "contributors": ["Author Name", "ACME CORP."], - "contributors_search": ["author name", "acme corp"], + "tags": [ + "sci-fi", + "space opera" + ], + "contributors": [ + "Author Name", + "ACME CORP." + ], "language": "en", "page_count": 350, "genre": "Science Fiction", - "copyright_year": 2023, - "created_at": "2026-01-31T10:00:00Z" + "created_at": "2026-01-31T10:00:00Z", + "abridged": null, + "age_rating": null, + "alternate_info": null, + "asin": null, + "authors": null, + "community_rating": null, + "date_published": null, + "edition": null, + "explicit": null, + "format_group": null, + "format_mimetype": null, + "imprint": null, + "is_black_and_white": null, + "isbn": null, + "library_type_name": null, + "manga_type": null, + "metadata_notes": null, + "narrators": null, + "publisher": null, + "publishers": null, + "reading_direction": null, + "scan_information": null, + "series_count": null, + "story_arc": null, + "subtitle": null, + "summary": null, + "updated_at": null, + "volume": null, + "web_url": null } ``` diff --git a/docs/developer/api/media-items/list_media_items.md b/docs/developer/api/media-items/list_media_items.md index 23ac03f8..9264a864 100644 --- a/docs/developer/api/media-items/list_media_items.md +++ b/docs/developer/api/media-items/list_media_items.md @@ -1,3 +1,11 @@ +--- +method: GET +path: /api/media-items +auth: jwt +area: media-items +--- + + # List Media Items Retrieve a paginated list of media items, scoped to a library or across all libraries. @@ -41,7 +49,7 @@ The response body is `{"data": [...]}` in both modes. The item shape differs by Nullable columns serialize as `null`. -```json +```json example-response { "data": [ { diff --git a/docs/developer/api/media-items/search_media_items.md b/docs/developer/api/media-items/search_media_items.md index ee0fc109..ae499aee 100644 --- a/docs/developer/api/media-items/search_media_items.md +++ b/docs/developer/api/media-items/search_media_items.md @@ -1,4 +1,12 @@ -# Search Media Items (Unified) +--- +method: GET +path: /api/media-items/search +auth: jwt +area: media-items +--- + + +# Search Media Items (Unified):<|end|><|start|>edit to=assistant<|channel|>analysis<|message|>Edited docs/developer/api/media-items/create_media_item.md (1 replacement)<|end|><|start|>edit to=assistant<|channel|>analysis<|message|>Edited docs/developer/api/media-items/update_media_item.md (1 replacement)<|end|><|start|>edit to=assistant<|channel|>analysis<|channel|>Edited docs/developer/api/media-items/delete_media_item.md (1 replacement)<|end|><|start|>edit to=assistant<|channel|>analysis<|message|>Edited docs/developer/api/media-items/search_media_items.md (1 replacement) Search and filter media items with fuzzy matching support. @@ -142,15 +150,88 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ## Response (200 OK) **Media items search:** -```json +```json example-response [ { "id": "uuid", "title": "Foundation", "author": "Isaac Asimov", - "tags": ["Science Fiction", "Adventure"], + "tags": [ + "Science Fiction", + "Adventure" + ], "library_id": "uuid", - "library_name": "E-Books" + "library_name": "E-Books", + "abridged": null, + "added_by_admin_id": null, + "age_rating": null, + "alternate_info": null, + "archived_at": null, + "asin": null, + "audio_metadata": null, + "authors": null, + "authors_search": null, + "chapter_count": null, + "chapter_metadata": null, + "community_rating": null, + "contributors": null, + "contributors_search": null, + "copyright_year": null, + "cover_image_path": null, + "created_at": null, + "date_published": null, + "description": null, + "edition": null, + "entitlement_id": null, + "explicit": null, + "file_path": null, + "file_sha256": null, + "file_size": null, + "format_group": null, + "format_mimetype": null, + "genre": null, + "goodreads_id": null, + "google_books_id": null, + "has_fixed_layout": null, + "hash_confidence": null, + "imported_at": null, + "imprint": null, + "is_black_and_white": null, + "is_reflowable": null, + "isbn": null, + "kobo_content_id": null, + "kobo_metadata": null, + "language": null, + "library_type_name": null, + "library_type_name_2": null, + "manga_type": null, + "metadata_notes": null, + "metadata_overrides": null, + "mime_type": null, + "missing_scan_count": null, + "narrators": null, + "narrators_search": null, + "openlibrary_id": null, + "opf_identifier": null, + "opf_uuid": null, + "page_count": null, + "publisher": null, + "publishers": null, + "publishers_search": null, + "reading_direction": null, + "revision_number": null, + "scan_information": null, + "series": null, + "series_count": null, + "series_number": null, + "story_arc": null, + "subtitle": null, + "summary": null, + "tags_search": null, + "total_characters": null, + "updated_at": null, + "volume": null, + "web_url": null } ] ``` diff --git a/docs/developer/api/media-items/update_media_item.md b/docs/developer/api/media-items/update_media_item.md index 2732088a..cb46d6a2 100644 --- a/docs/developer/api/media-items/update_media_item.md +++ b/docs/developer/api/media-items/update_media_item.md @@ -1,3 +1,11 @@ +--- +method: PUT +path: /api/media-items/:id +auth: jwt+admin +area: media-items +--- + + # Update Media Item Update media item metadata (Admin only). @@ -33,7 +41,7 @@ Update media item metadata (Admin only). ### Example Request -```json +```json example-request { "title": "Updated Title", "author": "Updated Author", @@ -45,7 +53,7 @@ Update media item metadata (Admin only). ## Response (200 OK) -```json +```json example-response { "id": "uuid", "title": "Updated Title", @@ -53,7 +61,74 @@ Update media item metadata (Admin only). "description": "Updated description", "series": "Series", "series_number": 2, - "updated_at": "2026-01-31T10:00:00Z" + "updated_at": "2026-01-31T10:00:00Z", + "abridged": null, + "added_by_admin_id": null, + "age_rating": null, + "alternate_info": null, + "archived_at": null, + "asin": null, + "audio_metadata": null, + "authors": null, + "authors_search": null, + "chapter_count": null, + "chapter_metadata": null, + "community_rating": null, + "contributors": null, + "contributors_search": null, + "copyright_year": null, + "cover_image_path": null, + "created_at": null, + "date_published": null, + "edition": null, + "entitlement_id": null, + "explicit": null, + "file_path": null, + "file_sha256": null, + "file_size": null, + "format_group": null, + "format_mimetype": null, + "genre": null, + "goodreads_id": null, + "google_books_id": null, + "has_fixed_layout": null, + "hash_confidence": null, + "imported_at": null, + "imprint": null, + "is_black_and_white": null, + "is_reflowable": null, + "isbn": null, + "kobo_content_id": null, + "kobo_metadata": null, + "language": null, + "library_id": null, + "library_type_name": null, + "manga_type": null, + "metadata_notes": null, + "metadata_overrides": null, + "mime_type": null, + "missing_scan_count": null, + "narrators": null, + "narrators_search": null, + "openlibrary_id": null, + "opf_identifier": null, + "opf_uuid": null, + "page_count": null, + "publisher": null, + "publishers": null, + "publishers_search": null, + "reading_direction": null, + "revision_number": null, + "scan_information": null, + "series_count": null, + "story_arc": null, + "subtitle": null, + "summary": null, + "tags": null, + "tags_search": null, + "total_characters": null, + "volume": null, + "web_url": null } ``` diff --git a/docs/developer/api/progress/get_media_progress.md b/docs/developer/api/progress/get_media_progress.md index 02853b3d..55b50e97 100644 --- a/docs/developer/api/progress/get_media_progress.md +++ b/docs/developer/api/progress/get_media_progress.md @@ -1,3 +1,11 @@ +--- +method: GET +path: /api/media-items/:id/progress +auth: jwt +area: media-items +--- + + # Get Media Item Progress Get the stored reading progress for a media item, plus the @@ -37,7 +45,7 @@ returned (not 404): ## Response (200 OK) — progress stored -```json +```json example-response { "id": "e8239659-0ed6-42ae-a946-8440d7655b42", "media_item_id": "774641f9-317b-4087-8e04-53bb4392ae56", @@ -59,7 +67,15 @@ returned (not 404): "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" + "context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim", + "audio_offset_seconds": null, + "panel_number": null, + "reading_mode": null, + "scroll_position_x": null, + "scroll_position_y": null, + "viewport_x": null, + "viewport_y": null, + "zoom_level": null } ``` diff --git a/docs/developer/api/progress/update_media_progress.md b/docs/developer/api/progress/update_media_progress.md index fbd852d4..bbe8cf05 100644 --- a/docs/developer/api/progress/update_media_progress.md +++ b/docs/developer/api/progress/update_media_progress.md @@ -1,3 +1,11 @@ +--- +method: PUT +path: /api/media-items/:id/progress +auth: jwt +area: media-items +--- + + # Update Media Item Progress Submit reading progress for a media item. This is the position-authority @@ -41,7 +49,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIs... Content-Type: application/json ``` -```json +```json example-request { "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", @@ -77,6 +85,36 @@ Content-Type: application/json ## Response (200 OK) +```json example-response +{ + "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)", + "context_text": null, + "chapter": null, + "chapter_progress": null, + "audio_offset_seconds": null, + "viewport_x": null, + "viewport_y": null, + "zoom_level": null, + "scroll_position_x": null, + "scroll_position_y": null, + "panel_number": null, + "reading_mode": "reflowable", + "last_sync_device": "web", + "last_sync_source": "koreader", + "last_sync_timestamp": "2026-09-26T14:52:34.806446Z", + "conflict_detected": false, + "conflict_resolved": false +} +``` + 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 diff --git a/docs/developer/api/ratings/create_rating.md b/docs/developer/api/ratings/create_rating.md index 18a54001..e8f37363 100644 --- a/docs/developer/api/ratings/create_rating.md +++ b/docs/developer/api/ratings/create_rating.md @@ -1,3 +1,11 @@ +--- +method: POST +path: /api/media-items/:id/rating +auth: jwt +area: media-items +--- + + # Create Rating Set a rating for a media item. Creates a new rating or updates an existing one. @@ -20,7 +28,7 @@ Set a rating for a media item. Creates a new rating or updates an existing one. ### Example Request -```json +```json example-request { "rating": 8 } @@ -28,12 +36,14 @@ Set a rating for a media item. Creates a new rating or updates an existing one. ## Response (201 Created) -```json +```json example-response { "rating": 8, "user_id": "uuid", "media_item_id": "uuid", - "created_at": "2026-01-31T10:00:00Z" + "created_at": "2026-01-31T10:00:00Z", + "id": null, + "updated_at": null } ``` diff --git a/docs/developer/api/ratings/delete_rating.md b/docs/developer/api/ratings/delete_rating.md index a10a30e5..5c67edeb 100644 --- a/docs/developer/api/ratings/delete_rating.md +++ b/docs/developer/api/ratings/delete_rating.md @@ -1,3 +1,11 @@ +--- +method: DELETE +path: /api/media-items/:id/rating +auth: jwt +area: media-items +--- + + # Delete Rating Delete the current user's rating for a media item. @@ -20,7 +28,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ## Response (200 OK) -```json +```json example-response { "message": "Rating deleted successfully" } diff --git a/docs/developer/api/ratings/get_ratings.md b/docs/developer/api/ratings/get_ratings.md index c9fbe5a4..25511aab 100644 --- a/docs/developer/api/ratings/get_ratings.md +++ b/docs/developer/api/ratings/get_ratings.md @@ -1,3 +1,11 @@ +--- +method: GET +path: /api/media-items/:id/rating +auth: jwt +area: media-items +--- + + # Get Rating Retrieve the current user's rating for a media item. @@ -26,11 +34,14 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ## Response (200 OK) -```json +```json example-response { "rating": 8, "user_id": "uuid", - "media_item_id": "uuid" + "media_item_id": "uuid", + "created_at": null, + "id": null, + "updated_at": null } ``` diff --git a/docs/developer/api/ratings/update_rating.md b/docs/developer/api/ratings/update_rating.md index 6da8f5dc..684c7b29 100644 --- a/docs/developer/api/ratings/update_rating.md +++ b/docs/developer/api/ratings/update_rating.md @@ -1,3 +1,11 @@ +--- +method: PUT +path: /api/media-items/:id/rating +auth: jwt +area: media-items +--- + + # Update Rating Update an existing rating for a media item. @@ -23,7 +31,7 @@ Update an existing rating for a media item. ### Example Request -```json +```json example-request { "rating": 8, "review": "Great book! Very enjoyable read." @@ -32,13 +40,12 @@ Update an existing rating for a media item. ## Response (200 OK) -```json +```json example-response { "id": "uuid", "media_item_id": "uuid", "user_id": "uuid", "rating": 8, - "review": "Great book! Very enjoyable read.", "created_at": "2026-01-31T10:00:00Z", "updated_at": "2026-01-31T11:00:00Z" } diff --git a/internal/docs/schema.go b/internal/docs/schema.go index ff84dc2f..3e25e7cb 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -6,6 +6,7 @@ import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" + "bookhoard/internal/sync" ) // Schema pins the machine-checkable contract for one route. The drift gate @@ -439,4 +440,75 @@ var Schemas = map[string]Schema{ Request: handlers.DeviceAuthStatusRequest{}, Response: handlers.DeviceAuthStatusResponse{}, }, + + // --- Beam batch 4: media-items core --- + "GET /api/media-items": { + Response: json.RawMessage(`{"data": [{}]}`), + }, + "GET /api/media-items/:id": { + // Hand-built ~44-key map — pinned verbatim from the handler. + Response: json.RawMessage(`{ + "id": "", "library_id": "", "title": "", "subtitle": "", + "author": "", "authors": [], "isbn": "", "description": "", + "file_path": "", "file_size": 0, "mime_type": "", + "cover_image_path": "", "series": "", "series_number": 0, + "tags": [], "asin": "", "date_published": "", "publisher": "", + "publishers": [], "explicit": false, "contributors": [], + "language": "", "edition": "", "page_count": 0, "genre": "", + "created_at": "", "updated_at": "", "format_group": "", + "format_mimetype": "", "narrators": [], "abridged": false, + "manga_type": "", "reading_direction": "", "series_count": 0, + "volume": 0, "imprint": "", "age_rating": "", "web_url": "", + "metadata_notes": "", "community_rating": 0, "story_arc": "", + "is_black_and_white": false, "alternate_info": "", + "scan_information": "", "summary": "", "library_type_name": "" + }`), + }, + "POST /api/media-items": { + Request: handlers.CreateMediaItemRequest{}, + Response: database.MediaItems{}, + }, + "PUT /api/media-items/:id": { + Request: handlers.UpdateMediaItemRequest{}, + Response: database.MediaItems{}, + }, + "DELETE /api/media-items/:id": {}, + "GET /api/media-items/search": { + Response: []database.SearchMediaItemsRow{}, + }, + "POST /api/media-items/:id/rating": { + // NOTE: rating scale is 1-10 int (odd = half-star). + Request: json.RawMessage(`{"rating": 0}`), + Response: database.MediaRatings{}, + }, + "PUT /api/media-items/:id/rating": { + Request: json.RawMessage(`{"rating": 0}`), + Response: database.MediaRatings{}, + }, + "GET /api/media-items/:id/rating": { + Response: database.MediaRatings{}, // absent -> {"rating": null} + }, + "DELETE /api/media-items/:id/rating": { + Response: json.RawMessage(`{"message": ""}`), + }, + "GET /api/media-items/:id/progress": { + Response: json.RawMessage(`{ + "id": null, "media_item_id": null, "user_id": null, + "current_page": 0, "total_pages": null, "last_read_at": null, + "percentage": null, "character_offset": null, "epubcfi": null, + "chapter": null, "chapter_progress": null, "format_group": null, + "total_characters": null, "chapter_count": null, + "last_sync_device": null, "last_sync_source": null, + "last_sync_timestamp": null, "audio_offset_seconds": null, + "reading_mode": null, "viewport_x": null, "viewport_y": null, + "zoom_level": null, "scroll_position_x": null, + "scroll_position_y": null, "panel_number": null, + "css_selector": null, "anchor_href": null, + "char_offset": null, "context_text": null + }`), + }, + "PUT /api/media-items/:id/progress": { + Request: sync.SaveProgressRequest{}, + Response: database.ReadingProgress{}, + }, }