From c4b62af213a30a0651843459de33d435bca5c2b7 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 24 Oct 2026 14:46:22 -0400 Subject: [PATCH] =?UTF-8?q?docs(devices):=20Work=20Order=203=20batch=201?= =?UTF-8?q?=20=E2=80=94=206=20missing=20device=20endpoints=20+=20gate=20en?= =?UTF-8?q?tries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New frontmatter'd docs with handler-compatible examples: - devices/get_device.md (DeviceInfo shape) - devices/regenerate_device_token.md (token + sync_urls) - devices/get_device_mappings.md (wrapper + DeviceMappingSummary) - devices/update_device_mapping.md (UpdateDeviceMappingRequest) - devices/download_sidecar_config.md (sidecar config JSON file) - devices/link_book.md (LinkBookRequest; one UNVERIFIED marker on required-tag) Schema registry: 6 new entries; DeviceMappingSummary hoisted to package scope. Gate passes (the UNVERIFIED marker is logged, not a failure). Excludes /api/devices-page (htmx page route, per ground rules). --- .../api/devices/download_sidecar_config.md | 42 +++++++++++++ docs/developer/api/devices/get_device.md | 42 +++++++++++++ .../api/devices/get_device_mappings.md | 40 +++++++++++++ docs/developer/api/devices/link_book.md | 59 +++++++++++++++++++ .../api/devices/regenerate_device_token.md | 56 ++++++++++++++++++ .../api/devices/update_device_mapping.md | 47 +++++++++++++++ internal/docs/schema.go | 50 ++++++++++++++++ internal/handlers/collections.go | 24 ++++---- 8 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 docs/developer/api/devices/download_sidecar_config.md create mode 100644 docs/developer/api/devices/get_device.md create mode 100644 docs/developer/api/devices/get_device_mappings.md create mode 100644 docs/developer/api/devices/link_book.md create mode 100644 docs/developer/api/devices/regenerate_device_token.md create mode 100644 docs/developer/api/devices/update_device_mapping.md diff --git a/docs/developer/api/devices/download_sidecar_config.md b/docs/developer/api/devices/download_sidecar_config.md new file mode 100644 index 00000000..93a14fab --- /dev/null +++ b/docs/developer/api/devices/download_sidecar_config.md @@ -0,0 +1,42 @@ +--- +method: GET +path: /api/devices/:id/sidecar/download +auth: jwt +area: devices +--- + + +# Download Sidecar Config + +Generates and downloads a `.bookhoard.json` sidecar configuration file for a +device (used by the sidecar sync workflow). The body is a JSON document returned +with `Content-Disposition: attachment`. + +## Example response + +```json example-response +{ + "version": "1.0", + "bookhoard": { + "opds_catalog": "http://localhost:8765/opds/devices/550e8400-.../catalog", + "sync_api": "http://localhost:8765/api/sync/kobo", + "opds_base_url": "http://localhost:8765/opds", + "api_base_url": "http://localhost:8765/api", + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "device_token": "dev_abc123..." + }, + "books": {}, + "collections": [], + "opds_enabled": true, + "sidecar_enabled": true, + "last_updated": "2026-10-24T13:57:01Z" +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid device ID"}` | malformed id | +| 404 | `{"error": "device not found"}` | no such device | +| 500 | `{"error": "failed to fetch media items"}` / `{"error": "failed to generate config"}` | unexpected server error | diff --git a/docs/developer/api/devices/get_device.md b/docs/developer/api/devices/get_device.md new file mode 100644 index 00000000..29d5d44c --- /dev/null +++ b/docs/developer/api/devices/get_device.md @@ -0,0 +1,42 @@ +--- +method: GET +path: /api/devices/:id +auth: jwt +area: devices +--- + + +# Get Device + +Returns one device owned by the signed-in user. Device tokens are **not** +included in this response (use `regenerate-token` to obtain them). + +## Example response + +```json example-response +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "device_name": "Kobo Clara", + "device_type": "kobo", + "last_sync": "2026-10-24T13:57:01Z", + "last_seen": "2026-10-24T13:57:01Z", + "sync_enabled": true, + "auto_sync": true, + "sync_frequency_minutes": 60, + "created_at": "2026-10-24T13:57:01Z", + "auth_token": "" +} +``` + +`last_sync` and `last_seen` are `*time.Time` → `null` when unset. `created_at` +is RFC3339 (`time.Time` marshaling). `device_metadata` is omitted when empty +(`omitempty`). + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid user ID"}` / `{"error": "invalid device ID"}` | malformed id(s) | +| 401 | `{"error": "unauthorized"}` | no/invalid JWT | +| 403 | `{"error": "access denied"}` | device belongs to another user | +| 404 | `{"error": "device not found"}` | no such device | diff --git a/docs/developer/api/devices/get_device_mappings.md b/docs/developer/api/devices/get_device_mappings.md new file mode 100644 index 00000000..e25467d8 --- /dev/null +++ b/docs/developer/api/devices/get_device_mappings.md @@ -0,0 +1,40 @@ +--- +method: GET +path: /api/devices/:id/collections +auth: jwt +area: devices +--- + + +# Get Device Collection Mappings + +Lists the shelf-to-collection mappings configured for a device. + +## Example response + +```json example-response +{ + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "mappings": [ + { + "id": "660e8400-e29b-41d4-a716-446655440000", + "collection_id": "770e8400-e29b-41d4-a716-446655440000", + "collection_name": "To Read", + "device_shelf_name": "Kobo", + "sync_direction": "bidirectional", + "created_at": "2026-10-24 13:57:01.81234 +0000 UTC" + } + ], + "total": 1 +} +``` + +`created_at` uses Go's `time.String()` format, not RFC3339. `total` is the +length of `mappings`. + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid device id"}` | malformed id | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/devices/link_book.md b/docs/developer/api/devices/link_book.md new file mode 100644 index 00000000..94749e4d --- /dev/null +++ b/docs/developer/api/devices/link_book.md @@ -0,0 +1,59 @@ +--- +method: POST +path: /api/devices/:deviceId/sync/link-book +auth: jwt +area: devices +--- + + +# Link Book + +Links a device file (by path + SHA256) to a media item, optionally with a +confidence score. Used by the book-matching flow. + +## Example request + +```json example-request +{ + "device_file": { + "file_path": "/books/1984.epub", + "sha256": "abc123...", + "title": "1984" + }, + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "confidence_score": 0.95 +} +``` + +## Example response + +```json example-response +{ + "status": "linked", + "device_file_alias": { + "id": "660e8400-e29b-41d4-a716-446655440000", + "media_item_id": "550e8400-e29b-41d4-a716-446655440000", + "device_id": "770e8400-e29b-41d4-a716-446655440000", + "file_path": "/books/1984.epub", + "file_sha256": "abc123...", + "confidence_score": 0.95 + } +} +``` + +## Request fields + +| Field | Type | Required | Notes | +| ----- | ---- | -------- | ----- | +| device_file.file_path | string | no | path on the device | +| device_file.sha256 | string | no | file content hash | +| device_file.title | string | no | device-side title | +| media_item_id | string (uuid) | yes | `services.LinkBookRequest` declares it as uuid; the gate only checks the Bind struct — UNVERIFIED: confirm required tag on LinkBookRequest fields | +| confidence_score | number | no | match confidence (0-1) | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "Invalid device ID"}` / `{"error": "Invalid request body"}` | malformed id or body | +| 500 | `{"error": "Failed to link book: ..."}` | service failure | diff --git a/docs/developer/api/devices/regenerate_device_token.md b/docs/developer/api/devices/regenerate_device_token.md new file mode 100644 index 00000000..7fe04445 --- /dev/null +++ b/docs/developer/api/devices/regenerate_device_token.md @@ -0,0 +1,56 @@ +--- +method: PUT +path: /api/devices/:id/regenerate-token +auth: jwt +area: devices +--- + + +# Regenerate Device Token + +Mints a fresh device auth token and returns it together with device-specific +sync URLs. The previous token is invalidated. + +For Kobo devices the response includes `sync_urls` (init/markup/bookmark/sync_url); +for KOReader devices it contains the KOReader endpoints. + +## Example response + +```json example-response +{ + "message": "Token regenerated successfully", + "auth_token": "dev_abc123...", + "device": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "device_name": "Kobo Clara", + "device_type": "kobo", + "last_sync": null, + "last_seen": null, + "sync_enabled": true, + "auto_sync": true, + "sync_frequency_minutes": 60, + "created_at": "2026-10-24T13:57:01Z", + "device_metadata": null + }, + "sync_urls": { + "sync_url": "http://localhost:8765/api/sync/kobo/dev_abc123...", + "markup": "http://localhost:8765/api/sync/kobo/dev_abc123.../markup", + "bookmark": "http://localhost:8765/api/sync/kobo/dev_abc123.../bookmark", + "init": "http://localhost:8765/api/sync/kobo/dev_abc123.../v1/initialization" + } +} +``` + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid device ID"}` | malformed id | +| 401 | `{"error": "invalid user ID"}` | no/invalid JWT | +| 403 | `{"error": "access denied"}` | device belongs to another user | +| 404 | `{"error": "device not found"}` | no such device | +| 500 | `{"error": "..."}` | token generation / DB failure | + +Notes: HTMX requests get an HTML reload snippet instead of JSON. +`sync_urls` keys differ by device type (Kobo gets the four above; KOReader +gets its own set). diff --git a/docs/developer/api/devices/update_device_mapping.md b/docs/developer/api/devices/update_device_mapping.md new file mode 100644 index 00000000..8a8e9767 --- /dev/null +++ b/docs/developer/api/devices/update_device_mapping.md @@ -0,0 +1,47 @@ +--- +method: PUT +path: /api/devices/:id/collections/:collectionId +auth: jwt +area: devices +--- + + +# Update Device Collection Mapping + +Updates a device shelf-to-collection mapping's shelf name and sync direction. + +## Example request + +```json example-request +{ + "device_shelf_name": "Kobo", + "sync_direction": "bidirectional" +} +``` + +## Example response + +```json example-response +{ + "id": "660e8400-e29b-41d4-a716-446655440000", + "collection_id": "770e8400-e29b-41d4-a716-446655440000", + "device_id": "550e8400-e29b-41d4-a716-446655440000", + "device_shelf_name": "Kobo", + "sync_direction": "bidirectional", + "created_at": "2026-10-24 13:57:01.81234 +0000 UTC" +} +``` + +## Request fields + +| Field | Type | Required | Notes | +| ----- | ---- | -------- | ----- | +| device_shelf_name | string | yes | | +| sync_direction | string | yes | one of `bidirectional`, `book_to_device`, `device_to_book`, `none` | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid mapping id"}` / `{"error": "invalid request"}` | malformed id or body | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/internal/docs/schema.go b/internal/docs/schema.go index 9ec4ea8d..f41652b2 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -4,6 +4,7 @@ import ( "encoding/json" "bookhoard/internal/handlers" + "bookhoard/internal/services" ) // Schema pins the machine-checkable contract for one route. The drift gate @@ -69,4 +70,53 @@ var Schemas = map[string]Schema{ }`), }, "DELETE /api/collections/:id": {}, + // --- devices (batch 1) --- + "GET /api/devices/:id": { + Response: handlers.DeviceInfo{}, + }, + "PUT /api/devices/:id/regenerate-token": { + Response: json.RawMessage(`{ + "message": "", "auth_token": "", + "device": { + "id": "", "device_name": "", "device_type": "", + "last_sync": null, "last_seen": null, + "sync_enabled": false, "auto_sync": false, + "sync_frequency_minutes": 0, "created_at": "", + "device_metadata": null, "auth_token": "" + }, + "sync_urls": {} + }`), + }, + "GET /api/devices/:id/collections": { + Response: struct { + DeviceID string `json:"device_id"` + Mappings []handlers.DeviceMappingSummary `json:"mappings"` + Total int `json:"total"` + }{}, + }, + "PUT /api/devices/:id/collections/:collectionId": { + Request: handlers.UpdateDeviceMappingRequest{}, + Response: json.RawMessage(`{ + "id": "", "collection_id": "", "device_id": "", + "device_shelf_name": "", "sync_direction": "", "created_at": "" + }`), + }, + "GET /api/devices/:id/sidecar/download": { + Response: json.RawMessage(`{ + "version": "", "bookhoard": {}, "books": {}, + "collections": [], "opds_enabled": false, + "sidecar_enabled": false, "last_updated": "" + }`), + }, + "POST /api/devices/:deviceId/sync/link-book": { + Request: services.LinkBookRequest{}, + Response: json.RawMessage(`{ + "status": "", + "device_file_alias": { + "id": "", "media_item_id": "", "device_id": "", + "file_path": "", "file_sha256": "", + "confidence_score": 0.0 + } + }`), + }, } diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 09839ae7..a12e6859 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -75,6 +75,17 @@ type UpdateDeviceMappingRequest struct { SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"` } +// DeviceMappingSummary is one entry in GET /api/devices/:id/collections. Hoisted +// to package scope so the docs schema registry can pin the response shape. +type DeviceMappingSummary struct { + ID uuid.UUID `json:"id"` + CollectionID uuid.UUID `json:"collection_id"` + CollectionName string `json:"collection_name"` + DeviceShelfName string `json:"device_shelf_name"` + SyncDirection string `json:"sync_direction"` + CreatedAt string `json:"created_at"` +} + // CollectionSummary is one entry in GET /api/collections. Package-level so // the docs schema registry (internal/docs) can pin the response shape. type CollectionSummary struct { @@ -653,18 +664,9 @@ func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - type MappingResponse struct { - ID uuid.UUID `json:"id"` - CollectionID uuid.UUID `json:"collection_id"` - CollectionName string `json:"collection_name"` - DeviceShelfName string `json:"device_shelf_name"` - SyncDirection string `json:"sync_direction"` - CreatedAt string `json:"created_at"` - } - - response := make([]MappingResponse, 0, len(mappings)) + response := make([]DeviceMappingSummary, 0, len(mappings)) for _, m := range mappings { - response = append(response, MappingResponse{ + response = append(response, DeviceMappingSummary{ ID: m.ID.Bytes, CollectionID: m.CollectionID.Bytes, CollectionName: m.CollectionName,