diff --git a/bruno/koreader/Get Book Metadata.bru b/bruno/koreader/Get Book Metadata.bru new file mode 100644 index 0000000..9f6b6cf --- /dev/null +++ b/bruno/koreader/Get Book Metadata.bru @@ -0,0 +1,20 @@ +meta { + name: KOReader Get Book Metadata + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/sync/koreader/metadata/{{book_uuid}} + headers: { + Authorization: Bearer {{device_token}}, + Content-Type: application/json + } +} + +assert { + res.status == 200 + res.body.uuid != null + res.body.title != null + res.body.progress != null +} diff --git a/bruno/koreader/Get Library.bru b/bruno/koreader/Get Library.bru new file mode 100644 index 0000000..6d37926 --- /dev/null +++ b/bruno/koreader/Get Library.bru @@ -0,0 +1,19 @@ +meta { + name: KOReader Get Library + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/sync/koreader/library + headers: { + Authorization: Bearer {{device_token}}, + Content-Type: application/json + } +} + +assert { + res.status == 200 + res.body.library_sync != null + res.body.total_books >= 0 +} diff --git a/bruno/koreader/README.md b/bruno/koreader/README.md new file mode 100644 index 0000000..5502e95 --- /dev/null +++ b/bruno/koreader/README.md @@ -0,0 +1,307 @@ +# KOReader Integration + +## Overview + +Bookmann provides full Calibre-compatible wireless sync for KOReader devices, enabling seamless reading progress, highlights, and notes synchronization. + +## Setup + +### 1. Register Your Device + +First, register your KOReader device with Bookmann: + +```bash +POST /api/devices/register +{ + "device_name": "My Kindle Paperwhite", + "device_type": "koreader", + "device_identifier": "unique-hardware-id" +} +``` + +You'll receive: +- A registration URL to approve the device +- A QR code for easy setup +- Setup instructions for your device type + +### 2. Approve Device + +Visit the approval URL in your web browser (or scan the QR code) to authenticate the device. + +### 3. Configure KOReader + +In KOReader settings, set: +- **Calibre wireless URL**: `https://your-bookmann-domain.com/api/sync/koreader` +- **Enable wireless sync**: ON +- **Sync frequency**: Every page turn (recommended) + +### 4. Enter Device Token + +After approval, you'll receive a device token. Add this to KOReader: +- Settings → Wireless sync → Password +- Paste the token: `dev_xxxxx...` + +## API Endpoints + +### Sync Progress + +Updates reading progress for one or more books. + +```bash +POST /api/sync/koreader/progress +Authorization: Bearer {device_token} +Content-Type: application/json + +{ + "books": [ + { + "uuid": "book-uuid", + "title": "Book Title", + "authors": ["Author Name"], + "percentage": 0.45, + "progress": 0.45, + "chapter": 5, + "character": 15432, + "epubcfi": "epubcfi(/6/4/2:15)", + "page": 89, + "total_pages": 200, + "last_read": "2026-01-30T20:00:00Z" + } + ], + "sync_mode": "immediate" +} +``` + +**Response** (202 Accepted): +```json +{ + "sync_status": "accepted", + "books_synced": 1, + "conflicts": [], + "timestamp": "2026-01-30T20:00:00Z", + "device_updated": true +} +``` + +### Get Book Metadata + +Fetches progress and annotations for a specific book. + +```bash +GET /api/sync/koreader/metadata/{book_uuid} +Authorization: Bearer {device_token} +``` + +**Response** (200 OK): +```json +{ + "uuid": "book-uuid", + "title": "Book Title", + "authors": ["Author Name"], + "progress": { + "percentage": 0.45, + "chapter": 5, + "epubcfi": "epubcfi(/6/4/2:15)", + "character": 15432, + "page": 89, + "total_pages": 200 + }, + "annotations": { + "highlights": [ + { + "text": "highlighted text", + "pos0": "epubcfi(/6/4/2:15)", + "pos1": "epubcfi(/6/4/2:20)", + "color": "#ffff00", + "datetime": "2026-01-30T19:55:00Z" + } + ], + "notes": [ + { + "text": "My note", + "pos0": "epubcfi(/6/4/2:15)", + "datetime": "2026-01-30T19:55:00Z" + } + ] + }, + "last_sync": "2026-01-30T20:00:00Z" +} +``` + +### Get Library + +Fetches all books available for sync. + +```bash +GET /api/sync/koreader/library +Authorization: Bearer {device_token} +``` + +**Response** (200 OK): +```json +{ + "library_sync": [ + { + "uuid": "book-uuid", + "title": "Book Title", + "author": "Author Name", + "content_type": "6", + "percent_read": 45.0, + "pages_remaining": 115, + "bookmark_count": 3, + "last_modified": "2026-01-30T20:00:00Z" + } + ], + "total_books": 10, + "last_sync": "2026-01-30T20:00:00Z" +} +``` + +### Sync Bookmarks/Notes/Highlights + +Syncs annotations for a specific book. + +```bash +POST /api/sync/koreader/bookmarks +Authorization: Bearer {device_token} +Content-Type: application/json + +{ + "book_uuid": "book-uuid", + "bookmarks": [ + { + "chapter": 3, + "datetime": "2026-01-30T19:55:00Z", + "pos0": "epubcfi(/6/4/2:15)", + "page": 45, + "text": "Bookmarked text", + "type": "bookmark" + } + ], + "highlights": [ + { + "chapter": 3, + "datetime": "2026-01-30T19:55:00Z", + "pos0": "epubcfi(/6/4/2:15)", + "pos1": "epubcfi(/6/4/2:20)", + "page": 45, + "text": "highlighted text", + "color": "#ffff00" + } + ], + "notes": [ + { + "chapter": 3, + "datetime": "2026-01-30T19:55:00Z", + "pos0": "epubcfi(/6/4/2:15)", + "notes": "My note content", + "page": 45 + } + ] +} +``` + +**Response** (200 OK): +```json +{ + "sync_status": "completed", + "bookmarks_synced": 1, + "notes_synced": 1, + "highlights_synced": 1, + "total_synced": 3, + "timestamp": "2026-01-30T20:00:00Z" +} +``` + +## Sync Modes + +### Immediate Mode (Recommended) +Syncs on every page turn for real-time updates across all devices. + +```json +{ "sync_mode": "immediate" } +``` + +### Checkpoint Mode +Syncs periodically to save bandwidth. + +```json +{ + "sync_mode": "checkpoint", + "checkpoint_id": "checkpoint-uuid", + "since_timestamp": "2026-01-30T19:00:00Z" +} +``` + +## Rate Limits + +- **Sync requests**: 60/minute +- **Progress updates**: 120/minute +- **Metadata requests**: 30/minute + +## Device Matching + +Bookmann tries multiple strategies to match books: + +1. **By UUID**: Most reliable if your book files have unique IDs +2. **By file path**: Matches exact file path +3. **By title + author**: Fallback for unmatched books + +## Conflict Resolution + +When the same book is read on multiple devices within 5 minutes: + +- Auto-resolution uses "most recent progress wins" +- Conflicts are logged for review +- Users can manually resolve conflicts via the web UI + +## Troubleshooting + +### Sync Not Working + +1. **Check device token**: Ensure token is valid and not revoked +2. **Verify sync enabled**: Device must have `sync_enabled: true` +3. **Check rate limits**: Device may be rate-limited +4. **Book matching**: Ensure books can be matched by UUID, path, or title + +### Progress Not Updating + +1. **Verify percentage value**: Must be between 0.0 and 1.0 +2. **Check book ownership**: User must have access to the book +3. **Review sync logs**: Check for error messages + +### Authentication Errors + +1. **Token expired**: Re-register device +2. **Device revoked**: Check device status in web UI +3. **Invalid token format**: Ensure `Bearer dev_xxxxx...` format + +## Testing + +Use the Bruno API collection in `/bruno/koreader/` to test endpoints: + +- `Sync Progress.bru` - Test progress sync +- `Get Book Metadata.bru` - Test metadata retrieval +- `Get Library.bru` - Test library sync +- `Sync Bookmarks.bru` - Test annotation sync + +Required variables: +- `baseUrl` - Your Bookmann server URL +- `device_token` - Device authentication token +- `book_uuid` - UUID of a test book + +## Implementation Notes + +- Compatible with Calibre wireless protocol +- Supports EPUB CFI for precise locations +- Handles reflowable and fixed-layout formats +- Bidirectional sync (KOReader ↔ Bookmann) +- Real-time updates via WebSocket (coming in Phase 3b) + +## Next Steps + +See Phase 3 implementation guide for: +- WebSocket real-time sync +- Advanced conflict resolution +- Offline sync queue management +- Performance optimization diff --git a/bruno/koreader/Sync Bookmarks.bru b/bruno/koreader/Sync Bookmarks.bru new file mode 100644 index 0000000..4c439a6 --- /dev/null +++ b/bruno/koreader/Sync Bookmarks.bru @@ -0,0 +1,60 @@ +meta { + name: KOReader Sync Bookmarks + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/sync/koreader/bookmarks + body: json({ + "book_uuid": "{{book_uuid}}", + "bookmarks": [ + { + "chapter": 3, + "datetime": "2026-01-30T19:55:00Z", + "notes": "Bookmarked text", + "pos0": "epubcfi(/6/4/2:15)", + "pos1": "epubcfi(/6/4/2:20)", + "page": 45, + "text": "This is important", + "type": "highlight", + "percentage": 0.45 + } + ], + "notes": [ + { + "chapter": 3, + "datetime": "2026-01-30T19:55:00Z", + "notes": "My note here", + "pos0": "epubcfi(/6/4/2:15)", + "page": 45, + "text": "Note content", + "type": "note" + } + ], + "highlights": [ + { + "chapter": 3, + "datetime": "2026-01-30T19:55:00Z", + "notes": "highlighted text", + "pos0": "epubcfi(/6/4/2:15)", + "pos1": "epubcfi(/6/4/2:20)", + "page": 45, + "text": "highlighted text excerpt", + "type": "highlight", + "color": "#ffff00", + "percentage": 0.45 + } + ] + }) + headers: { + Authorization: Bearer {{device_token}}, + Content-Type: application/json + } +} + +assert { + res.status == 200 + res.body.sync_status == "completed" + res.body.total_synced >= 0 +} diff --git a/bruno/koreader/Sync Progress.bru b/bruno/koreader/Sync Progress.bru new file mode 100644 index 0000000..f8b0738 --- /dev/null +++ b/bruno/koreader/Sync Progress.bru @@ -0,0 +1,42 @@ +meta { + name: KOReader Sync Progress + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/sync/koreader/progress + body: json({ + "library_id": null, + "books": [ + { + "uuid": "{{book_uuid}}", + "title": "Test Book", + "authors": ["Test Author"], + "progress": 0.45, + "percentage": 0.45, + "last_read": "2026-01-30T20:00:00Z", + "chapter": 5, + "character": 15432, + "epubcfi": "epubcfi(/6/4/2:15)", + "page": 89, + "total_pages": 200 + } + ], + "sync_mode": "immediate", + "device_info": { + "koreader_version": "2024.01", + "device_model": "kindle-paperwhite-5" + } + }) + headers: { + Authorization: Bearer {{device_token}}, + Content-Type: application/json + } +} + +assert { + res.status == 202 + res.body.sync_status == "accepted" + res.body.books_synced >= 0 +} diff --git a/cmd/server/main.go b/cmd/server/main.go index c18b4b3..26067b1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,9 +50,9 @@ func main() { authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) libraryHandler := handlers.NewLibraryHandler(queries) deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) + koreaderHandler := handlers.NewKOReaderHandler(queries) deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries) - _ = deviceAuthMiddleware e := echo.New() @@ -181,6 +181,13 @@ func main() { e.GET("/devices/approve/:registration_id", deviceHandler.ApproveDevice) e.POST("/devices/reject/:registration_id", deviceHandler.RejectDevice) + // KOReader sync routes (device authentication required) + koreaderSync := e.Group("/api/sync/koreader") + koreaderSync.POST("/progress", deviceAuthMiddleware.Authenticate(koreaderHandler.SyncProgress)) + koreaderSync.GET("/metadata/:uuid", deviceAuthMiddleware.Authenticate(koreaderHandler.GetMetadata)) + koreaderSync.GET("/library", deviceAuthMiddleware.Authenticate(koreaderHandler.GetLibrary)) + koreaderSync.POST("/bookmarks", deviceAuthMiddleware.Authenticate(koreaderHandler.SyncBookmarks)) + // Device management routes (protected - require user auth) devices := protected.Group("/devices") devices.GET("", deviceHandler.ListDevices) diff --git a/cmd/server/tests/koreader_test.go b/cmd/server/tests/koreader_test.go new file mode 100644 index 0000000..2141f77 --- /dev/null +++ b/cmd/server/tests/koreader_test.go @@ -0,0 +1,297 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestKOReaderSyncProgress_RequestBody(t *testing.T) { + t.Run("valid request body", func(t *testing.T) { + reqBody := map[string]interface{}{ + "books": []map[string]interface{}{ + { + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "percentage": 0.45, + "progress": 0.45, + "chapter": 5, + }, + }, + "sync_mode": "immediate", + } + + body, err := json.Marshal(reqBody) + assert.NoError(t, err) + assert.Contains(t, string(body), "percentage") + + req := httptest.NewRequest("POST", "/api/sync/koreader/progress", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "/api/sync/koreader/progress", req.URL.Path) + }) + + t.Run("request with multiple books", func(t *testing.T) { + reqBody := map[string]interface{}{ + "books": []map[string]interface{}{ + { + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "percentage": 0.25, + }, + { + "uuid": "660e8400-e29b-41d4-a716-446655440001", + "percentage": 0.50, + }, + }, + "sync_mode": "checkpoint", + } + + body, err := json.Marshal(reqBody) + assert.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + assert.NoError(t, err) + + books := parsed["books"].([]interface{}) + assert.Len(t, books, 2) + }) + + t.Run("request with highlights and bookmarks", func(t *testing.T) { + reqBody := map[string]interface{}{ + "book_uuid": "550e8400-e29b-41d4-a716-446655440000", + "bookmarks": []map[string]interface{}{ + { + "chapter": 3, + "page": 45, + "text": "Bookmarked text", + }, + }, + "highlights": []map[string]interface{}{ + { + "chapter": 3, + "page": 45, + "text": "highlighted text", + "color": "#ffff00", + }, + }, + } + + body, err := json.Marshal(reqBody) + assert.NoError(t, err) + assert.Contains(t, string(body), "bookmarks") + assert.Contains(t, string(body), "highlights") + }) +} + +func TestKOReaderMetadataParsing(t *testing.T) { + t.Run("parse progress data", func(t *testing.T) { + progressData := map[string]interface{}{ + "percentage": 0.45, + "chapter": 5, + "epubcfi": "epubcfi(/6/4/2:15)", + "character": int64(15432), + "page": 89, + "total_pages": 200, + "chapter_progress": 0.234, + } + + body, err := json.Marshal(progressData) + assert.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + assert.NoError(t, err) + + assert.Equal(t, 0.45, parsed["percentage"]) + assert.Equal(t, "epubcfi(/6/4/2:15)", parsed["epubcfi"]) + }) + + t.Run("parse annotation data", func(t *testing.T) { + annotations := map[string]interface{}{ + "highlights": []map[string]interface{}{ + { + "text": "highlighted text", + "pos0": "epubcfi(/6/4/2:15)", + "pos1": "epubcfi(/6/4/2:20)", + "color": "#ffff00", + }, + }, + "notes": []map[string]interface{}{ + { + "text": "My note", + "pos0": "epubcfi(/6/4/2:15)", + }, + }, + } + + body, err := json.Marshal(annotations) + assert.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + assert.NoError(t, err) + + highlights := parsed["highlights"].([]interface{}) + assert.Len(t, highlights, 1) + + notes := parsed["notes"].([]interface{}) + assert.Len(t, notes, 1) + }) +} + +func TestKOReaderResponseFormats(t *testing.T) { + t.Run("sync progress response", func(t *testing.T) { + response := map[string]interface{}{ + "sync_status": "accepted", + "books_synced": 1, + "conflicts": []interface{}{}, + "timestamp": "2026-01-30T20:00:00Z", + "device_updated": true, + } + + body, err := json.Marshal(response) + assert.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + assert.NoError(t, err) + + assert.Equal(t, "accepted", parsed["sync_status"]) + assert.Equal(t, float64(1), parsed["books_synced"]) + }) + + t.Run("metadata response", func(t *testing.T) { + response := map[string]interface{}{ + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "title": "Test Book", + "authors": []string{"Test Author"}, + "progress": map[string]interface{}{ + "percentage": 0.45, + "chapter": 5, + }, + "annotations": map[string]interface{}{ + "highlights": []interface{}{}, + "notes": []interface{}{}, + }, + } + + body, err := json.Marshal(response) + assert.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + assert.NoError(t, err) + + assert.Equal(t, "Test Book", parsed["title"]) + assert.NotNil(t, parsed["progress"]) + }) + + t.Run("library response", func(t *testing.T) { + response := map[string]interface{}{ + "library_sync": []map[string]interface{}{ + { + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "title": "Book 1", + "author": "Author 1", + "percent_read": 45.0, + "pages_remaining": 115, + "bookmark_count": 3, + }, + }, + "total_books": 1, + "last_sync": "2026-01-30T20:00:00Z", + } + + body, err := json.Marshal(response) + assert.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + assert.NoError(t, err) + + assert.Equal(t, float64(1), parsed["total_books"]) + }) +} + +func TestKOReaderErrorHandling(t *testing.T) { + t.Run("invalid UUID format", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/sync/koreader/metadata/invalid-uuid", nil) + + // This should fail UUID parsing + _, err := uuid.Parse(req.URL.Path[len("/api/sync/koreader/metadata/"):]) + assert.Error(t, err) + }) + + t.Run("missing authorization header", func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/sync/koreader/progress", nil) + req.Header.Set("Content-Type", "application/json") + + authHeader := req.Header.Get("Authorization") + assert.Empty(t, authHeader) + }) + + t.Run("invalid percentage value", func(t *testing.T) { + progressData := map[string]interface{}{ + "percentage": 1.5, // Invalid: > 1.0 + } + + body, err := json.Marshal(progressData) + assert.NoError(t, err) + + var parsed map[string]interface{} + err = json.Unmarshal(body, &parsed) + assert.NoError(t, err) + + percentage := parsed["percentage"].(float64) + assert.Greater(t, percentage, 1.0) + }) +} + +func TestKOReaderDeviceMatching(t *testing.T) { + t.Run("match by UUID", func(t *testing.T) { + bookUUID := "550e8400-e29b-41d4-a716-446655440000" + bookData := map[string]interface{}{ + "uuid": bookUUID, + "percentage": 0.45, + } + + body, _ := json.Marshal(bookData) + var parsed map[string]interface{} + json.Unmarshal(body, &parsed) + + assert.Equal(t, bookUUID, parsed["uuid"]) + }) + + t.Run("match by file path", func(t *testing.T) { + bookData := map[string]interface{}{ + "file_path": "/path/to/book.epub", + "percentage": 0.45, + } + + body, _ := json.Marshal(bookData) + var parsed map[string]interface{} + json.Unmarshal(body, &parsed) + + assert.Equal(t, "/path/to/book.epub", parsed["file_path"]) + }) + + t.Run("match by title and author", func(t *testing.T) { + bookData := map[string]interface{}{ + "title": "Test Book", + "authors": []string{"Test Author"}, + "percentage": 0.45, + } + + body, _ := json.Marshal(bookData) + var parsed map[string]interface{} + json.Unmarshal(body, &parsed) + + assert.Equal(t, "Test Book", parsed["title"]) + assert.NotNil(t, parsed["authors"]) + }) +}