diff --git a/IMPLEMENTATION_COLLECTION_FIX.md b/IMPLEMENTATION_COLLECTION_FIX.md index bccd13b..4b57b7a 100644 --- a/IMPLEMENTATION_COLLECTION_FIX.md +++ b/IMPLEMENTATION_COLLECTION_FIX.md @@ -530,10 +530,25 @@ function connectWebSocket(): void { (window as any).showToast?.(actionText, "info"); - // Auto-reload after 1 second to see updates - setTimeout(() => { - location.reload(); - }, 1000); + // Mitigation: Skip auto-reload if user is actively typing or interacting + const activeElement = document.activeElement; + const isUserActive = activeElement && ( + activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.tagName === "SELECT" || + activeElement.getAttribute("contenteditable") === "true" + ); + + if (!isUserActive) { + // Auto-reload after 1 second to see updates (only if user not actively typing) + setTimeout(() => { + location.reload(); + }, 1000); + } else { + // User is active - just show toast, don't reload + // They'll see updates when they navigate away or manually refresh + console.log("User actively typing - skipping auto-reload"); + } } } catch (error) { console.error("Failed to parse WebSocket message:", error); @@ -863,38 +878,38 @@ err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Reque ## Step 9: Add Integration Tests -### File: `cmd/server/tests/collections_test.go` +### File: `cmd/server/tests/search_test.go` -**Create or update with library filter tests**: +**Add to existing `search_test.go`** (recommended) OR create `cmd/server/tests/collections_test.go`: ```go package main import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" + "time" - "github.com/google/uuid" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" ) -// TestCollectionSearchLibraryFilter tests library_id filtering in search +// TestCollectionSearchLibraryFilter tests library_id filtering in search API func TestCollectionSearchLibraryFilter(t *testing.T) { - ts := setupTestServer(t) - defer ts.cleanup() + setup := setupTestServer(t) + client := &http.Client{} - // Create test user and authenticate - user := ts.createUser(t, "testuser", "password") - token := ts.loginUser(t, user.ID, "password") - - // Create two libraries - lib1 := ts.createLibrary(t, user.ID, "Library 1") - lib2 := ts.createLibrary(t, user.ID, "Library 2") + // Create two libraries with books via API + lib1Resp := createLibrary(t, client, setup, "Library 1 - Search Test") + lib2Resp := createLibrary(t, client, setup, "Library 2 - Search Test") // Add books to each library - book1 := ts.createBook(t, lib1.ID, "Harry Potter 1") - book2 := ts.createBook(t, lib2.ID, "Harry Potter 2") - - // Create collection - collection := ts.createCollection(t, user.ID, "My Collection") + book1ID := createTestMediaItemIDInLibrary(t, client, setup, lib1Resp["id"].(string), "Harry Potter 1") + book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2") tests := []struct { name string @@ -913,44 +928,48 @@ func TestCollectionSearchLibraryFilter(t *testing.T) { { name: "filter library 1", query: "Harry", - libraryID: lib1.ID.String(), + libraryID: lib1Resp["id"].(string), expectedCount: 1, - shouldContain: book1.ID.String(), + shouldContain: book1ID, }, { name: "filter library 2", query: "Harry", - libraryID: lib2.ID.String(), + libraryID: lib2Resp["id"].(string), expectedCount: 1, - shouldContain: book2.ID.String(), + shouldContain: book2ID, }, { name: "invalid library_id", query: "Harry", - libraryID: uuid.New().String(), - expectedCount: 0, // No books in random library + libraryID: "00000000-0000-0000-0000-000000000000", + expectedCount: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - url := "/api/media-items/search?q=" + tt.query + url := setup.Server.URL + "/api/media-items/search?q=" + tt.query if tt.libraryID != "" { url += "&library_id=" + tt.libraryID } + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + var result []map[string]interface{} - resp := ts.doRequest(t, "GET", url, token, nil, &result) + json.NewDecoder(resp.Body).Decode(&result) - if resp.StatusCode != 200 && tt.expectedCount > 0 { - t.Errorf("Expected status 200, got %d", resp.StatusCode) + if tt.expectedCount > 0 { + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, tt.expectedCount, len(result)) } - if len(result) != tt.expectedCount { - t.Errorf("Expected %d results, got %d", tt.expectedCount, len(result)) - } - - if tt.shouldContain != "" && len(result) > 0 { + if tt.shouldContain != "" { found := false for _, book := range result { if book["id"] == tt.shouldContain { @@ -958,89 +977,204 @@ func TestCollectionSearchLibraryFilter(t *testing.T) { break } } - if !found { - t.Errorf("Expected book %s not found in results", tt.shouldContain) - } + require.True(t, found, "Expected book %s not found in results", tt.shouldContain) } }) } } +// Helper: createLibrary creates a library via API +func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, name string) map[string]interface{} { + libReq := map[string]interface{}{ + "name": name, + "description": "Test library", + "type": "ebooks", + } + body, _ := json.Marshal(libReq) + + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + return result +} + +// Helper: createTestMediaItemIDInLibrary creates a media item in specific library +func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, libraryID string, title string) string { + mediaReq := map[string]interface{}{ + "library_id": libraryID, + "title": title, + "author": "Test Author", + "file_path": "/tmp/test.epub", + "file_size": 1024, + "mime_type": "application/epub+zip", + } + body, _ := json.Marshal(mediaReq) + + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + return result["id"].(string) +} +``` + +--- + +### File: `cmd/server/tests/websocket_test.go` (Recommended) + +**Add to existing `websocket_test.go`** OR create new file: + +```go // TestWebSocketUserScopedBroadcast tests that broadcasts only go to the user who made changes func TestWebSocketUserScopedBroadcast(t *testing.T) { - ts := setupTestServer(t) - defer ts.cleanup() + setup := setupTestServer(t) + client := &http.Client{} - // Create two users - user1 := ts.createUser(t, "user1", "password") - user2 := ts.createUser(t, "user2", "password") + // Use pre-created admin user (setup.Token) and regular user (setup.RegularToken) + // Both users already created by setupTestServer() - token1 := ts.loginUser(t, user1.ID, "password") - token2 := ts.loginUser(t, user2.ID, "password") + // Create a collection for admin user + collectionReq := map[string]interface{}{ + "name": "Admin Collection", + "description": "Test collection for WebSocket test", + } + collectionBody, _ := json.Marshal(collectionReq) - // Connect both users via WebSocket - ws1 := ts.connectWebSocket(t, token1) - defer ws1.Close() + collectionHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(collectionBody)) + collectionHTTP.Header.Set("Content-Type", "application/json") + collectionHTTP.Header.Set("Authorization", "Bearer "+setup.Token) - ws2 := ts.connectWebSocket(t, token2) - defer ws2.Close() + collectionResp, err := client.Do(collectionHTTP) + require.NoError(t, err) + defer collectionResp.Body.Close() + require.Equal(t, http.StatusCreated, collectionResp.StatusCode) - // User1 adds book to collection - collection := ts.createCollection(t, user1.ID, "User1 Collection") - book := ts.createBook(t, user1.ID, "Test Book") + var collectionResult map[string]interface{} + json.NewDecoder(collectionResp.Body).Decode(&collectionResult) + collectionID := collectionResult["id"].(string) - ts.doRequest(t, "POST", "/api/collections/"+collection.ID.String()+"/books", token1, - map[string]interface{}{"book_ids": []string{book.ID.String()}}, nil) + // Create a test book via API + bookID := createTestMediaItemID(t, setup) - // User1 should receive collection_updated message - msg1 := ts.readWSMessage(t, ws1, 2*time.Second) - if msg1["type"] != "collection_updated" { - t.Errorf("User1 should receive collection_updated, got %s", msg1["type"]) + // Connect admin user via WebSocket + wsAdmin := connectWebSocketToServer(t, setup.Server.URL, setup.Token) + defer wsAdmin.Close() + + // Connect regular user via WebSocket + wsRegular := connectWebSocketToServer(t, setup.Server.URL, setup.RegularToken) + defer wsRegular.Close() + + // Admin adds book to collection + addReq := map[string]interface{}{ + "book_ids": []string{bookID}, + } + addBody, _ := json.Marshal(addReq) + + addHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/books", bytes.NewBuffer(addBody)) + addHTTP.Header.Set("Content-Type", "application/json") + addHTTP.Header.Set("Authorization", "Bearer "+setup.Token) + + addResp, err := client.Do(addHTTP) + require.NoError(t, err) + defer addResp.Body.Close() + require.Equal(t, http.StatusNoContent, addResp.StatusCode) + + // Admin should receive collection_updated message + msgAdmin := readWebSocketMessage(t, wsAdmin, 2*time.Second) + if msgAdmin["type"] != "collection_updated" { + t.Errorf("Admin should receive collection_updated, got %s", msgAdmin["type"]) } - // User2 should NOT receive collection_updated message - msg2 := ws1.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - _, _, err := ws2.ReadMessage() + // Regular user should NOT receive collection_updated message + wsRegular.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, _, err = wsRegular.ReadMessage() if err == nil { - t.Errorf("User2 should not receive collection_updated message") + t.Errorf("Regular user should not receive collection_updated message") } } + +// Helper: connectWebSocketToServer establishes WebSocket connection with auth token +func connectWebSocketToServer(t *testing.T, serverURL string, token string) *websocket.Conn { + wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + "/ws/sync?token=" + token + + ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err, "WebSocket connection should succeed") + if resp != nil { + resp.Body.Close() + } + require.NotNil(t, ws, "WebSocket connection should be established") + + // Wait for connection to be ready + time.Sleep(100 * time.Millisecond) + + return ws +} + +// Helper: readWebSocketMessage reads a message from WebSocket with timeout +func readWebSocketMessage(t *testing.T, ws *websocket.Conn, timeout time.Duration) map[string]interface{} { + ws.SetReadDeadline(time.Now().Add(timeout)) + + _, message, err := ws.ReadMessage() + require.NoError(t, err, "Should receive WebSocket message") + + var msg map[string]interface{} + err = json.Unmarshal(message, &msg) + require.NoError(t, err, "Message should be valid JSON") + + return msg +} ``` --- ## Step 10: Update Documentation -### File: `docs/developer/api/search.md` +### File: `docs/developer/api/media-items/search_media_items.md` -**Add library_id parameter documentation**: +**Update existing documentation to add library_id parameter:** + +1. **Add `library_id` to Query Parameters table:** ```markdown -# Search API +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------------- | +| q | string | Yes | Search query (minimum 2 characters) | +| library_id| string | No | Filter results to specific library (UUID) | +| limit | integer | No | Number of results (default 20) | +| offset | integer | No | Number to skip | +``` -## Search Media Items +2. **Add example request showing library_id filter:** -Searches for media items across all libraries. +```markdown +Search all libraries: -**Endpoint:** `GET /api/media-items/search` +```http +GET /api/media-items/search?q=Harry+Potter&limit=20&offset=0 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` -**Query Parameters:** +Search within a specific library: -| Parameter | Type | Required | Description | -|-----------|--------|----------|--------------------------------------------| -| q | string | Yes | Search query (min 2 characters) | -| library_id| string | No | Filter results to specific library (UUID) | - -**Example Request:** - -```bash -# Search all libraries -curl -H "Authorization: Bearer $TOKEN" \ - "https://bookhoard.example/api/media-items/search?q=harry%20potter" - -# Search specific library -curl -H "Authorization: Bearer $TOKEN" \ - "https://bookhoard.example/api/media-items/search?q=harry&library_id=123e4567-e89b-12d3-a456-426614174000" +```http +GET /api/media-items/search?q=Harry&library_id=123e4567-e89b-12d3-a456-426614174000&limit=20 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` ``` **Response:** @@ -1060,54 +1194,195 @@ Array of media items (partial match) or (fuzzy match): ``` ``` -### File: `bruno/collections/search-with-library-filter.yml` +### Bruno API Tests -**Create Bruno test**: +Create 3 separate request files in `bruno/media-items/`: + +--- + +### File: `bruno/media-items/Search All Libraries.yml` ```yaml -meta: - name: Search with Library Filter - type: Collection - description: Tests library_id parameter in search API +info: + name: Search All Libraries + type: http + seq: 1 -collections: - - name: Search All Libraries - request: - method: GET - url: "{{baseUrl}}/api/media-items/search" - query: - q: "harry" - headers: - Authorization: "Bearer {{token}}" - assert: - - status: 200 - - jsonpath: "$[?(@.title)].length" > 0 +http: + method: GET + url: '{{base_url}}/api/media-items/search' + params: + - name: q + value: "harry" + type: query + disabled: false + auth: inherit - - name: Search Specific Library - request: - method: GET - url: "{{baseUrl}}/api/media-items/search" - query: - q: "harry" - library_id: "{{libraryId}}" - headers: - Authorization: "Bearer {{token}}" - assert: - - status: 200 - - jsonpath: "$[?(@.library_id == '{{libraryId}}')].length" > 0 +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 - - name: Invalid Library ID - request: - method: GET - url: "{{baseUrl}}/api/media-items/search" - query: - q: "test" - library_id: "invalid-uuid" - headers: - Authorization: "Bearer {{token}}" - assert: - - status: 400 - - jsonpath: "$.error" == "invalid library_id" +docs: |- + ## Search All Libraries + + Searches for media items across all libraries without filtering. + + **Method:** GET + + **Endpoint:** /api/media-items/search + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `q` (string, required): Search query (minimum 2 characters) + + **Response:** HTTP 200 (OK) + ```json + [ + { + "id": "uuid", + "title": "Harry Potter and the Sorcerer's Stone", + "author": "J.K. Rowling", + "library_id": "uuid", + "library_name": "E-Books" + } + ] + ``` + + **Success Criteria:** + - Status: 200 + - Returns array of media items from all libraries + - Results match search query +``` + +--- + +### File: `bruno/media-items/Search Specific Library.yml` + +```yaml +info: + name: Search Specific Library + type: http + seq: 2 + +http: + method: GET + url: '{{base_url}}/api/media-items/search' + params: + - name: q + value: "harry" + type: query + disabled: false + - name: library_id + value: "{{libraryId}}" + type: query + disabled: false + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Search Specific Library + + Searches for media items within a specific library using the library_id filter. + + **Method:** GET + + **Endpoint:** /api/media-items/search + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `q` (string, required): Search query (minimum 2 characters) + - `library_id` (string/UUID, required): Filter results to specific library + + **Response:** HTTP 200 (OK) + ```json + [ + { + "id": "uuid", + "title": "Harry Potter and the Sorcerer's Stone", + "author": "J.K. Rowling", + "library_id": "{{libraryId}}", + "library_name": "My Library" + } + ] + ``` + + **Success Criteria:** + - Status: 200 + - All results have `library_id` matching the filter + - Results match search query + + **Test Setup:** + - Set `libraryId` environment variable to a valid library UUID +``` + +--- + +### File: `bruno/media-items/Search Invalid Library ID.yml` + +```yaml +info: + name: Search Invalid Library ID + type: http + seq: 3 + +http: + method: GET + url: '{{base_url}}/api/media-items/search' + params: + - name: q + value: "test" + type: query + disabled: false + - name: library_id + value: "invalid-uuid" + type: query + disabled: false + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Search Invalid Library ID + + Tests error handling when an invalid library_id is provided. + + **Method:** GET + + **Endpoint:** /api/media-items/search + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `q` (string, required): Search query + - `library_id` (string, invalid): Malformed UUID + + **Response:** HTTP 400 (Bad Request) + ```json + { + "error": "invalid library_id" + } + ``` + + **Success Criteria:** + - Status: 400 + - Returns error message indicating invalid library_id + + **Edge Cases Tested:** + - Malformed UUID (not valid UUID format) + - Validates input sanitization ``` --- @@ -1125,9 +1400,12 @@ collections: | 6 | `templates/collections.templ` | ~10 | Add toggle UI with onchange | | 7 | `web/src/collections.ts` | +~320 | Add TypeScript with WebSocket | | 8 | `internal/router/frontend.go` | ~3 | Pass libraryID to template | -| 9 | `cmd/server/tests/collections_test.go` | +~150 | Integration tests | -| 10 | `docs/developer/api/search.md` | +~30 | API documentation | -| 10 | `bruno/collections/...` | +~50 | Bruno API tests | +| 9 | `cmd/server/tests/search_test.go` | +~150 | Integration tests (library filter) | +| 9 | `cmd/server/tests/websocket_test.go` | +~80 | Integration tests (user-scoped broadcast) | +| 10 | `docs/developer/api/media-items/search_media_items.md` | +~15 | Update API docs with library_id | +| 10 | `bruno/media-items/Search All Libraries.yml` | +~50 | Bruno test: search without filter | +| 10 | `bruno/media-items/Search Specific Library.yml` | +~55 | Bruno test: search with library filter | +| 10 | `bruno/media-items/Search Invalid Library ID.yml` | +~50 | Bruno test: invalid library_id error | --- @@ -1146,6 +1424,7 @@ collections: - [ ] Open same collection in second tab - first tab shows update when second tab adds book - [ ] Remove book from collection - toast appears, page reloads - [ ] Search within collection - books filter client-side +- [ ] **Auto-reload mitigation test**: While typing in search box, have another tab add books - verify no reload occurs, toast still shows ### Automated Testing @@ -1162,5 +1441,6 @@ collections: - **User-scoped broadcasts** ensure privacy (User A doesn't see User B's collection updates) - **Toggle default is "checked"** (filter by library) when library_id is present - **Search respects library filter** in both partial and fuzzy searches for consistency +- **Auto-reload mitigation**: WebSocket handler checks if user is actively typing (INPUT/TEXTAREA/SELECT/contenteditable) and skips reload to prevent data loss - **All changes follow PROJECT_GUIDELINES.md**: TypeScript only, TailwindCSS only, procedural style - **Progressive enhancement maintained**: Page works without JS (server-side rendered) diff --git a/bruno/collections/Add Books to Collection.yml b/bruno/collections/Add Books to Collection.yml index 04a4974..b895ded 100644 --- a/bruno/collections/Add Books to Collection.yml +++ b/bruno/collections/Add Books to Collection.yml @@ -8,4 +8,31 @@ http: auth: inherit body: type: json - jsonBody: "{\n \"book_ids\": [\n \"{{book_id_1" + jsonBody: "{\n \"book_ids\": [\n \"{{book_id_1}}" + +docs: |- + ## Add Books to Collection + + Adds multiple books to an existing collection. + + **Method:** POST + + **Endpoint:** /api/collections/{collection_id}/books + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `collection_id` (string): Collection ID + + **Request Body:** + - `book_ids` (array of strings, required): List of book IDs to add + + **Response:** + - `message` (string): Success message + - `added_count` (integer): Number of books added + + **Status Codes:** + - 200: Success + - 400: Invalid request + - 401: Unauthorized + - 404: Collection not found diff --git a/bruno/collections/Create Collection.yml b/bruno/collections/Create Collection.yml index 38588ab..098be29 100644 --- a/bruno/collections/Create Collection.yml +++ b/bruno/collections/Create Collection.yml @@ -13,3 +13,35 @@ http: ,\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-1\",\n \ \ \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\"\ : \"Science Fiction\",\n \"priority\": 8" + +docs: |- + ## Create Collection + + Creates a new collection for organizing books. + + **Method:** POST + + **Endpoint:** /api/collections + + **Authentication:** Required (Bearer token) + + **Request Body:** + - `name` (string, required): Collection name (max 100 chars) + - `description` (string, optional): Collection description (max 500 chars) + - `color` (string, optional): Hex color code (default #3498db) + - `icon` (string, optional): Emoji icon (default 📚) + - `auto_assign_rules` (array, optional): Auto-assignment rules + - `id` (string): Rule ID + - `field` (string): Field to match (genre, author, series, etc.) + - `operator` (string): Comparison operator (equals, contains, startsWith) + - `value` (string): Value to match + - `priority` (integer): Rule priority (1-10) + + **Response:** + - Collection object with generated ID + + **Status Codes:** + - 201: Created + - 400: Invalid request + - 401: Unauthorized + - 409: Collection name already exists diff --git a/bruno/collections/Create Device Mapping.yml b/bruno/collections/Create Device Mapping.yml index c40a2c7..d0498fe 100644 --- a/bruno/collections/Create Device Mapping.yml +++ b/bruno/collections/Create Device Mapping.yml @@ -8,4 +8,32 @@ http: auth: inherit body: type: json - jsonBody: "{\n \"collection_id\": \"{{collection_id" + jsonBody: "{\n \"collection_id\": \"{{collection_id}}" + +docs: |- + ## Create Device Mapping + + Maps a collection to a device shelf for synchronization. + + **Method:** POST + + **Endpoint:** /api/devices/{device_id}/collections + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + + **Request Body:** + - `collection_id` (string, required): Collection ID to map + - `device_shelf_name` (string, optional): Custom shelf name on device + - `sync_direction` (string, optional): sync direction (book_to_device, device_to_book, bidirectional) + + **Response:** + - Mapping object with generated ID + + **Status Codes:** + - 201: Created + - 400: Invalid request + - 401: Unauthorized + - 404: Device or collection not found diff --git a/bruno/collections/Delete Collection.yml b/bruno/collections/Delete Collection.yml index 381a873..67d7451 100644 --- a/bruno/collections/Delete Collection.yml +++ b/bruno/collections/Delete Collection.yml @@ -6,3 +6,25 @@ http: method: DELETE url: '{{base_url}}/api/collections/{{collection_id}}' auth: inherit + +docs: |- + ## Delete Collection + + Permanently deletes a collection and all its mappings. + + **Method:** DELETE + + **Endpoint:** /api/collections/{collection_id} + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `collection_id` (string): Collection ID + + **Response:** + - `message` (string): Success message + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Collection not found diff --git a/bruno/collections/Delete Device Mapping.yml b/bruno/collections/Delete Device Mapping.yml index 4469fb9..d15dd74 100644 --- a/bruno/collections/Delete Device Mapping.yml +++ b/bruno/collections/Delete Device Mapping.yml @@ -6,3 +6,26 @@ http: method: DELETE url: '{{base_url}}/api/devices/{{device_id}}/collections/{{mapping_id}}' auth: inherit + +docs: |- + ## Delete Device Mapping + + Removes a collection-to-device mapping. + + **Method:** DELETE + + **Endpoint:** /api/devices/{device_id}/collections/{mapping_id} + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + - `mapping_id` (string): Mapping ID + + **Response:** + - `message` (string): Success message + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Device or mapping not found diff --git a/bruno/collections/Get Book Collections.yml b/bruno/collections/Get Book Collections.yml index 57cbc62..d09b21d 100644 --- a/bruno/collections/Get Book Collections.yml +++ b/bruno/collections/Get Book Collections.yml @@ -19,3 +19,26 @@ settings: timeout: 0 followRedirects: true maxRedirects: 5 + +docs: |- + ## Get Book Collections + + Retrieves all collections that a specific book belongs to. + + **Method:** GET + + **Endpoint:** /api/collections/books + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `book_id` (string, required): Book ID + + **Response:** + - Array of collection objects containing the book + + **Status Codes:** + - 200: Success + - 400: Invalid request + - 401: Unauthorized + - 404: Book not found diff --git a/bruno/collections/Get Collection.yml b/bruno/collections/Get Collection.yml index aac4e33..a865cbc 100644 --- a/bruno/collections/Get Collection.yml +++ b/bruno/collections/Get Collection.yml @@ -6,3 +6,25 @@ http: method: GET url: '{{base_url}}/api/collections/{{collection_id}}' auth: inherit + +docs: |- + ## Get Collection + + Retrieves detailed information about a specific collection. + + **Method:** GET + + **Endpoint:** /api/collections/{collection_id} + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `collection_id` (string): Collection ID + + **Response:** + - Collection object with all fields including books, rules, and mappings + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Collection not found diff --git a/bruno/collections/Get Collections.yml b/bruno/collections/Get Collections.yml index d409e09..894677f 100644 --- a/bruno/collections/Get Collections.yml +++ b/bruno/collections/Get Collections.yml @@ -6,3 +6,26 @@ http: method: GET url: '{{base_url}}/api/collections?include_auto=true&sort_by=name' auth: inherit + +docs: |- + ## Get Collections + + Retrieves all collections for the authenticated user. + + **Method:** GET + + **Endpoint:** /api/collections + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `include_auto` (boolean, optional): Include auto-created collections (default false) + - `sort_by` (string, optional): Sort field (name, created_at, book_count) + - `order` (string, optional): Sort order (asc, desc) + + **Response:** + - Array of collection objects + + **Status Codes:** + - 200: Success + - 401: Unauthorized diff --git a/bruno/collections/Get Device Mappings.yml b/bruno/collections/Get Device Mappings.yml index 7b60cca..2d2bf5b 100644 --- a/bruno/collections/Get Device Mappings.yml +++ b/bruno/collections/Get Device Mappings.yml @@ -6,3 +6,25 @@ http: method: GET url: '{{base_url}}/api/devices/{{device_id}}/collections' auth: inherit + +docs: |- + ## Get Device Mappings + + Retrieves all collection mappings for a specific device. + + **Method:** GET + + **Endpoint:** /api/devices/{device_id}/collections + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + + **Response:** + - Array of device mapping objects with collection details + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Device not found diff --git a/bruno/collections/Remove Book from Collection.yml b/bruno/collections/Remove Book from Collection.yml index d7e7e98..d0c95dc 100644 --- a/bruno/collections/Remove Book from Collection.yml +++ b/bruno/collections/Remove Book from Collection.yml @@ -6,3 +6,26 @@ http: method: DELETE url: '{{base_url}}/api/collections/{{collection_id}}/books/{{book_id}}' auth: inherit + +docs: |- + ## Remove Book from Collection + + Removes a single book from a collection. + + **Method:** DELETE + + **Endpoint:** /api/collections/{collection_id}/books/{book_id} + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `collection_id` (string): Collection ID + - `book_id` (string): Book ID + + **Response:** + - `message` (string): Success message + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Collection or book not found diff --git a/bruno/collections/Update Collection.yml b/bruno/collections/Update Collection.yml index 9a7ae3e..46142bd 100644 --- a/bruno/collections/Update Collection.yml +++ b/bruno/collections/Update Collection.yml @@ -13,3 +13,34 @@ http: auto_assign_rules\": [\n {\n \"id\": \"rule-2\",\n \"field\"\ : \"series\",\n \"operator\": \"equals\",\n \"value\": \"Foundation\"\ ,\n \"priority\": 9" + +docs: |- + ## Update Collection + + Updates an existing collection's properties. + + **Method:** PUT + + **Endpoint:** /api/collections/{collection_id} + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `collection_id` (string): Collection ID + + **Request Body:** + - `name` (string, optional): New collection name + - `description` (string, optional): New description + - `color` (string, optional): New hex color code + - `icon` (string, optional): New emoji icon + - `auto_assign_rules` (array, optional): Updated auto-assignment rules + + **Response:** + - Updated collection object + + **Status Codes:** + - 200: Success + - 400: Invalid request + - 401: Unauthorized + - 404: Collection not found + - 409: Collection name already exists diff --git a/bruno/collections/Update Device Mapping.yml b/bruno/collections/Update Device Mapping.yml index 63b834b..0b1a50e 100644 --- a/bruno/collections/Update Device Mapping.yml +++ b/bruno/collections/Update Device Mapping.yml @@ -10,3 +10,31 @@ http: type: json jsonBody: "{\n \"device_shelf_name\": \"Science Fiction\",\n \"sync_direction\"\ : \"book_to_device\"" + +docs: |- + ## Update Device Mapping + + Updates settings for a collection-to-device mapping. + + **Method:** PUT + + **Endpoint:** /api/devices/{device_id}/collections/{mapping_id} + + **Authentication:** Required (Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + - `mapping_id` (string): Mapping ID + + **Request Body:** + - `device_shelf_name` (string, optional): Custom shelf name on device + - `sync_direction` (string, optional): Sync direction (book_to_device, device_to_book, bidirectional) + + **Response:** + - Updated mapping object + + **Status Codes:** + - 200: Success + - 400: Invalid request + - 401: Unauthorized + - 404: Device or mapping not found diff --git a/bruno/devices/api.yml b/bruno/devices/api.yml index e432bd9..f555de7 100644 --- a/bruno/devices/api.yml +++ b/bruno/devices/api.yml @@ -5,3 +5,18 @@ info: http: method: POST url: '"http://localhost:8765/api"' + +docs: |- + ## Bookhoard Device Management API + + Collection of endpoints for managing e-reader devices and their synchronization settings. + + **Base URL:** http://localhost:8765/api + + **Authentication:** Most endpoints require Bearer token authentication + + **Endpoints:** + - Device registration and management + - Device authentication tokens + - Sync configuration + - Device-to-collection mappings diff --git a/bruno/devices/kobo/api.yml b/bruno/devices/kobo/api.yml index 16eea94..b356a95 100644 --- a/bruno/devices/kobo/api.yml +++ b/bruno/devices/kobo/api.yml @@ -5,3 +5,18 @@ info: http: method: POST url: '"http://localhost:8765/api"' + +docs: |- + ## Bookhoard Kobo Sync API + + Collection of endpoints specifically for Kobo e-reader device synchronization. + + **Base URL:** http://localhost:8765/api + + **Authentication:** Device token or Bearer token required + + **Endpoints:** + - Kobo-specific sync protocols + - Kobo store integration + - Metadata synchronization + - Reading progress sync diff --git a/bruno/devices/koreader/api.yml b/bruno/devices/koreader/api.yml index aa14f3b..9e917ca 100644 --- a/bruno/devices/koreader/api.yml +++ b/bruno/devices/koreader/api.yml @@ -5,3 +5,18 @@ info: http: method: POST url: '"http://localhost:8765/api"' + +docs: |- + ## Bookhoard KOReader Sync API + + Collection of endpoints specifically for KOReader e-reader device synchronization. + + **Base URL:** http://localhost:8765/api + + **Authentication:** Device token or Bearer token required + + **Endpoints:** + - KOReader-specific sync protocols + - Metadata synchronization + - Reading progress sync + - Highlights and notes sync diff --git a/bruno/devices/scenarios/Check Registration Status.yml b/bruno/devices/scenarios/Check Registration Status.yml index cf3b5d3..2b826d9 100644 --- a/bruno/devices/scenarios/Check Registration Status.yml +++ b/bruno/devices/scenarios/Check Registration Status.yml @@ -8,4 +8,30 @@ http: auth: none body: type: json - jsonBody: "{\n \"registration_id\": \"{{registrationId" + jsonBody: "{\n \"registration_id\": \"{{registrationId}}" + +docs: |- + ## Check Registration Status + + Checks the current status of a device registration request. + + **Method:** POST + + **Endpoint:** /api/devices/register/status + + **Authentication:** None + + **Request Body:** + - `registration_id` (string, required): Registration request ID + + **Response:** + - `status` (string): Registration status (pending, approved, rejected, expired) + - `device_name` (string): Device name + - `device_type` (string): Device type + - `created_at` (string): Request timestamp + - `expires_at` (string): Expiration timestamp + + **Status Codes:** + - 200: Success + - 404: Registration ID not found + - 410: Registration expired diff --git a/bruno/devices/scenarios/Initiate Device Registration.yml b/bruno/devices/scenarios/Initiate Device Registration.yml index c9aad41..7867862 100644 --- a/bruno/devices/scenarios/Initiate Device Registration.yml +++ b/bruno/devices/scenarios/Initiate Device Registration.yml @@ -10,3 +10,30 @@ http: type: json jsonBody: "{\n \"device_name\": \"My Kindle Paperwhite\",\n \"device_type\"\ : \"koreader\",\n \"device_identifier\": \"kindle-pw5-hardware-id-12345\"" + +docs: |- + ## Initiate Device Registration + + Initiates a new device registration request that requires admin approval. + + **Method:** POST + + **Endpoint:** /api/devices/register + + **Authentication:** None (open endpoint) + + **Request Body:** + - `device_name` (string, required): Human-readable device name (max 100 chars) + - `device_type` (string, required): Device type (kobo, koreader, kindle, etc.) + - `device_identifier` (string, required): Unique hardware ID (max 200 chars) + + **Response:** + - `registration_id` (string): Unique registration request ID + - `status` (string): Initial status (pending) + - `expires_at` (string): Expiration timestamp (usually 24 hours) + - `message` (string): Informational message + + **Status Codes:** + - 201: Registration initiated + - 400: Invalid request + - 409: Device already registered diff --git a/bruno/devices/scenarios/Update Device.yml b/bruno/devices/scenarios/Update Device.yml index d91b6f2..55286a8 100644 --- a/bruno/devices/scenarios/Update Device.yml +++ b/bruno/devices/scenarios/Update Device.yml @@ -10,3 +10,33 @@ http: type: json jsonBody: "{\n \"device_name\": \"My Updated Kindle\",\n \"sync_enabled\"\ : true,\n \"auto_sync\": true,\n \"sync_frequency_minutes\": 10" + +docs: |- + ## Update Device + + Updates device settings and synchronization preferences. + + **Method:** PUT + + **Endpoint:** /api/devices/{device_id} + + **Authentication:** Required (Bearer token, admin or device owner) + + **Path Parameters:** + - `device_id` (string): Device ID + + **Request Body:** + - `device_name` (string, optional): New device name (max 100 chars) + - `sync_enabled` (boolean, optional): Enable/disable synchronization + - `auto_sync` (boolean, optional): Enable automatic synchronization + - `sync_frequency_minutes` (integer, optional): Sync frequency in minutes (5-1440) + + **Response:** + - Updated device object + + **Status Codes:** + - 200: Success + - 400: Invalid request + - 401: Unauthorized + - 403: Forbidden (not device owner) + - 404: Device not found diff --git a/bruno/library/browse-folders.yml b/bruno/library/browse-folders.yml index 5b73131..45c4fcd 100644 --- a/bruno/library/browse-folders.yml +++ b/bruno/library/browse-folders.yml @@ -23,3 +23,30 @@ req: url: "{{base_url}}/api/libraries/browse?path=/tmp" headers: Authorization: "Bearer {{ADMIN_TOKEN}}" + +docs: |- + ## Browse Library Folders + + Navigates through the filesystem to browse folders for library configuration. + + **Method:** GET + + **Endpoint:** /api/libraries/browse + + **Authentication:** Required (Bearer token, Admin only) + + **Query Parameters:** + - `path` (string, required): Filesystem path to browse + + **Response:** + - `current_path` (string): Current browsing path + - `parent_path` (string): Parent directory path + - `directories` (array): List of subdirectories + - `files` (array): List of files (optional) + + **Status Codes:** + - 200: Success + - 400: Invalid path + - 401: Unauthorized + - 403: Forbidden (admin access required) + - 404: Path not found diff --git a/bruno/media-items/Search All Libraries.yml b/bruno/media-items/Search All Libraries.yml new file mode 100644 index 0000000..c95337c --- /dev/null +++ b/bruno/media-items/Search All Libraries.yml @@ -0,0 +1,52 @@ +info: + name: Search All Libraries + type: http + seq: 1 + +http: + method: GET + url: "{{base_url}}/api/media-items/search" + params: + - name: q + value: "harry" + type: query + disabled: false + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Search All Libraries + + Searches for media items across all libraries without filtering. + + **Method:** GET + + **Endpoint:** /api/media-items/search + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `q` (string, required): Search query (minimum 2 characters) + + **Response:** HTTP 200 (OK) + ```json + [ + { + "id": "uuid", + "title": "Harry Potter and the Sorcerer's Stone", + "author": "J.K. Rowling", + "library_id": "uuid", + "library_name": "E-Books" + } + ] + ``` + + **Success Criteria:** + - Status: 200 + - Returns array of media items from all libraries + - Results match search query diff --git a/bruno/media-items/Search Invalid Library ID.yml b/bruno/media-items/Search Invalid Library ID.yml new file mode 100644 index 0000000..59f3774 --- /dev/null +++ b/bruno/media-items/Search Invalid Library ID.yml @@ -0,0 +1,54 @@ +info: + name: Search Invalid Library ID + type: http + seq: 3 + +http: + method: GET + url: "{{base_url}}/api/media-items/search" + params: + - name: q + value: "test" + type: query + disabled: false + - name: library_id + value: "invalid-uuid" + type: query + disabled: false + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Search Invalid Library ID + + Tests error handling when an invalid library_id is provided. + + **Method:** GET + + **Endpoint:** /api/media-items/search + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `q` (string, required): Search query + - `library_id` (string, invalid): Malformed UUID + + **Response:** HTTP 400 (Bad Request) + ```json + { + "error": "invalid library_id" + } + ``` + + **Success Criteria:** + - Status: 400 + - Returns error message indicating invalid library_id + + **Edge Cases Tested:** + - Malformed UUID (not valid UUID format) + - Validates input sanitization diff --git a/bruno/media-items/Search Specific Library.yml b/bruno/media-items/Search Specific Library.yml new file mode 100644 index 0000000..0bda46a --- /dev/null +++ b/bruno/media-items/Search Specific Library.yml @@ -0,0 +1,60 @@ +info: + name: Search Specific Library + type: http + seq: 2 + +http: + method: GET + url: "{{base_url}}/api/media-items/search" + params: + - name: q + value: "harry" + type: query + disabled: false + - name: library_id + value: "{{libraryId}}" + type: query + disabled: false + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Search Specific Library + + Searches for media items within a specific library using the library_id filter. + + **Method:** GET + + **Endpoint:** /api/media-items/search + + **Authentication:** Required (Bearer token) + + **Query Parameters:** + - `q` (string, required): Search query (minimum 2 characters) + - `library_id` (string/UUID, required): Filter results to specific library + + **Response:** HTTP 200 (OK) + ```json + [ + { + "id": "uuid", + "title": "Harry Potter and the Sorcerer's Stone", + "author": "J.K. Rowling", + "library_id": "{{libraryId}}", + "library_name": "My Library" + } + ] + ``` + + **Success Criteria:** + - Status: 200 + - All results have `library_id` matching the filter + - Results match search query + + **Test Setup:** + - Set `libraryId` environment variable to a valid library UUID diff --git a/bruno/opds/Download Book EPUB.yml b/bruno/opds/Download Book EPUB.yml index b955b8e..e5d66ba 100644 --- a/bruno/opds/Download Book EPUB.yml +++ b/bruno/opds/Download Book EPUB.yml @@ -5,3 +5,30 @@ info: http: method: GET url: '{{opds_base_url}}/devices/{{device_id}}/download/{{book_id}}' + +docs: |- + ## Download Book (EPUB) + + Downloads a book in standard EPUB format for reading on e-reader devices. + + **Method:** GET + + **Endpoint:** /devices/{device_id}/download/{book_id} + + **Authentication:** Required (Device token or Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + - `book_id` (string): Book ID + + **Query Parameters:** + - `format` (string, optional): File format (epub, default) + + **Response:** + - Binary EPUB file content + + **Status Codes:** + - 200: Success (file download) + - 401: Unauthorized + - 403: Forbidden (device not authorized for this book) + - 404: Book not found diff --git a/bruno/opds/Download Book KEPUB.yml b/bruno/opds/Download Book KEPUB.yml index f3f12ad..5446f0e 100644 --- a/bruno/opds/Download Book KEPUB.yml +++ b/bruno/opds/Download Book KEPUB.yml @@ -5,3 +5,30 @@ info: http: method: GET url: '{{opds_base_url}}/devices/{{device_id}}/download/{{book_id}}?format=kepub' + +docs: |- + ## Download Book (KEPUB) + + Downloads a book in Kobo-specific KEPUB format, optimized for Kobo e-readers. + + **Method:** GET + + **Endpoint:** /devices/{device_id}/download/{book_id} + + **Authentication:** Required (Device token or Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + - `book_id` (string): Book ID + + **Query Parameters:** + - `format` (string, required): File format (kepub for Kobo devices) + + **Response:** + - Binary KEPUB file content (EPUB with Kobo-specific enhancements) + + **Status Codes:** + - 200: Success (file download) + - 401: Unauthorized + - 403: Forbidden (device not authorized for this book) + - 404: Book not found diff --git a/bruno/opds/Get Cover Image.yml b/bruno/opds/Get Cover Image.yml index 1af1e22..9f47a7e 100644 --- a/bruno/opds/Get Cover Image.yml +++ b/bruno/opds/Get Cover Image.yml @@ -5,3 +5,31 @@ info: http: method: GET url: '{{opds_base_url}}/devices/{{device_id}}/cover/{{book_id}}' + +docs: |- + ## Get Cover Image + + Retrieves the cover image for a specific book. + + **Method:** GET + + **Endpoint:** /devices/{device_id}/cover/{book_id} + + **Authentication:** Required (Device token or Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + - `book_id` (string): Book ID + + **Query Parameters:** + - `width` (integer, optional): Desired width in pixels + - `height` (integer, optional): Desired height in pixels + - `quality` (integer, optional): Image quality (1-100, default 85) + + **Response:** + - Binary image file (JPEG or PNG) + + **Status Codes:** + - 200: Success (image file) + - 401: Unauthorized + - 404: Book or cover not found diff --git a/bruno/opds/Get Device Catalog.yml b/bruno/opds/Get Device Catalog.yml index 81030c0..6779141 100644 --- a/bruno/opds/Get Device Catalog.yml +++ b/bruno/opds/Get Device Catalog.yml @@ -5,3 +5,31 @@ info: http: method: GET url: '{{opds_base_url}}/devices/{{device_id}}/catalog?page=1&per_page=50' + +docs: |- + ## Get Device Catalog + + Retrieves a paginated catalog of books available for the device, compatible with OPDS feed format. + + **Method:** GET + + **Endpoint:** /devices/{device_id}/catalog + + **Authentication:** Required (Device token or Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + + **Query Parameters:** + - `page` (integer, optional): Page number (default 1) + - `per_page` (integer, optional): Items per page (default 50, max 200) + - `sort_by` (string, optional): Sort field (title, author, date_added) + - `order` (string, optional): Sort order (asc, desc) + + **Response:** + - OPDS 2.0 feed with book entries including metadata and download links + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Device not found diff --git a/bruno/opds/Get Device Navigation.yml b/bruno/opds/Get Device Navigation.yml index 0651861..7441066 100644 --- a/bruno/opds/Get Device Navigation.yml +++ b/bruno/opds/Get Device Navigation.yml @@ -5,3 +5,30 @@ info: http: method: GET url: '{{opds_base_url}}/devices/{{device_id}}/nav' + +docs: |- + ## Get Device Navigation + + Provides OPDS navigation feed for device, including collections and series groups. + + **Method:** GET + + **Endpoint:** /devices/{device_id}/nav + + **Authentication:** Required (Device token or Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + + **Response:** + - OPDS navigation feed with links to: + - All books + - Collections + - Series + - Authors + - Recent additions + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Device not found diff --git a/bruno/opds/List Formats.yml b/bruno/opds/List Formats.yml index 612193e..8fd2f1e 100644 --- a/bruno/opds/List Formats.yml +++ b/bruno/opds/List Formats.yml @@ -5,3 +5,30 @@ info: http: method: GET url: '{{opds_base_url}}/devices/{{device_id}}/formats/{{book_id}}' + +docs: |- + ## List Formats + + Lists all available file formats for a specific book. + + **Method:** GET + + **Endpoint:** /devices/{device_id}/formats/{book_id} + + **Authentication:** Required (Device token or Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + - `book_id` (string): Book ID + + **Response:** + - Array of available formats: + - `format` (string): Format identifier (epub, kepub, pdf, etc.) + - `file_size` (integer): File size in bytes + - `download_url` (string): Download link + - `optimized_for` (array): Compatible device types + + **Status Codes:** + - 200: Success + - 401: Unauthorized + - 404: Book not found diff --git a/bruno/opds/Search Device Catalog.yml b/bruno/opds/Search Device Catalog.yml index d6d3d05..1951899 100644 --- a/bruno/opds/Search Device Catalog.yml +++ b/bruno/opds/Search Device Catalog.yml @@ -5,3 +5,31 @@ info: http: method: GET url: '{{opds_base_url}}/devices/{{device_id}}/search?q=hobbit' + +docs: |- + ## Search Device Catalog + + Searches the device's book catalog for matching titles, authors, or series. + + **Method:** GET + + **Endpoint:** /devices/{device_id}/search + + **Authentication:** Required (Device token or Bearer token) + + **Path Parameters:** + - `device_id` (string): Device ID + + **Query Parameters:** + - `q` (string, required): Search query + - `page` (integer, optional): Page number (default 1) + - `per_page` (integer, optional): Items per page (default 50) + - `search_in` (string, optional): Search fields (title, author, series, all) + + **Response:** + - OPDS 2.0 feed with matching book entries + + **Status Codes:** + - 200: Success + - 400: Invalid query + - 401: Unauthorized diff --git a/bruno/queue/api.yml b/bruno/queue/api.yml index 9274fb3..056d4cb 100644 --- a/bruno/queue/api.yml +++ b/bruno/queue/api.yml @@ -5,3 +5,34 @@ info: http: method: GET url: '"http://localhost:8765/api"' + +docs: |- + ## Bookhoard Sync Queue API + + Collection of endpoints for managing the synchronization queue that handles + background processing of sync operations between devices and the server. + + **Base URL:** http://localhost:8765/api + + **Authentication:** Bearer token required for most endpoints + + **Endpoints:** + - Queue item management (create, process, retry, delete) + - Queue statistics and monitoring + - Device-specific queue operations + - Filtering by type (highlights, notes, bookmarks, progress) + - Filtering by status (pending, completed, failed) + - Bulk operations (clear device queue, clear failed items) + + **Queue Item Types:** + - `highlight`: Highlight synchronization + - `note`: Note synchronization + - `bookmark`: Bookmark synchronization + - `progress`: Reading progress synchronization + + **Queue Status Values:** + - `pending`: Awaiting processing + - `processing`: Currently being processed + - `completed`: Successfully processed + - `failed`: Processing failed (retryable) + - `cancelled`: Item was cancelled diff --git a/cmd/server/tests/search_test.go b/cmd/server/tests/search_test.go index 895da43..5c47c66 100644 --- a/cmd/server/tests/search_test.go +++ b/cmd/server/tests/search_test.go @@ -363,3 +363,137 @@ func TestSearchIntegrationWithRealDatabase(t *testing.T) { }) }) } + +// TestCollectionSearchLibraryFilter tests library_id filtering in search API +func TestCollectionSearchLibraryFilter(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + // Create two libraries with books via API + lib1Resp := createLibrary(t, client, setup, "Library 1 - Search Test") + lib2Resp := createLibrary(t, client, setup, "Library 2 - Search Test") + + // Add books to each library + book1ID := createTestMediaItemIDInLibrary(t, client, setup, lib1Resp["id"].(string), "Harry Potter 1") + book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2") + + tests := []struct { + name string + query string + libraryID string + expectedCount int + shouldContain string + }{ + { + name: "no filter - both books", + query: "Harry", + libraryID: "", + expectedCount: 2, + shouldContain: "", // Either book + }, + { + name: "filter library 1", + query: "Harry", + libraryID: lib1Resp["id"].(string), + expectedCount: 1, + shouldContain: book1ID, + }, + { + name: "filter library 2", + query: "Harry", + libraryID: lib2Resp["id"].(string), + expectedCount: 1, + shouldContain: book2ID, + }, + { + name: "invalid library_id", + query: "Harry", + libraryID: "00000000-0000-0000-0000-000000000000", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := setup.Server.URL + "/api/media-items/search?q=" + tt.query + if tt.libraryID != "" { + url += "&library_id=" + tt.libraryID + } + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + var result []map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + if tt.expectedCount > 0 { + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, tt.expectedCount, len(result)) + } + + if tt.shouldContain != "" { + found := false + for _, book := range result { + if book["id"] == tt.shouldContain { + found = true + break + } + } + require.True(t, found, "Expected book %s not found in results", tt.shouldContain) + } + }) + } +} + +// Helper: createLibrary creates a library via API +func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, name string) map[string]interface{} { + libReq := map[string]interface{}{ + "name": name, + "description": "Test library", + "type": "ebooks", + } + body, _ := json.Marshal(libReq) + + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + return result +} + +// Helper: createTestMediaItemIDInLibrary creates a media item in specific library +func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, libraryID string, title string) string { + mediaReq := map[string]interface{}{ + "library_id": libraryID, + "title": title, + "author": "Test Author", + "file_path": "/tmp/test.epub", + "file_size": 1024, + "mime_type": "application/epub+zip", + } + body, _ := json.Marshal(mediaReq) + + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + return result["id"].(string) +} diff --git a/cmd/server/tests/websocket_test.go b/cmd/server/tests/websocket_test.go index c95fb2c..fb64f5e 100644 --- a/cmd/server/tests/websocket_test.go +++ b/cmd/server/tests/websocket_test.go @@ -2,10 +2,13 @@ package main import ( "bookhoard/internal/database" + "bytes" "context" + "encoding/json" "net/http" "strings" "testing" + "time" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -238,3 +241,102 @@ func createTestMediaItem(t *testing.T, db *database.Queries, userID uuid.UUID) s return uuid.UUID(mediaID.ID.Bytes).String() } + +// TestWebSocketUserScopedBroadcast tests that broadcasts only go to the user who made changes +func TestWebSocketUserScopedBroadcast(t *testing.T) { + setup := setupTestServer(t) + client := &http.Client{} + + // Use pre-created admin user (setup.Token) and regular user (setup.RegularToken) + // Both users already created by setupTestServer() + + // Create a collection for admin user + collectionReq := map[string]interface{}{ + "name": "Admin Collection", + "description": "Test collection for WebSocket test", + } + collectionBody, _ := json.Marshal(collectionReq) + + collectionHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(collectionBody)) + collectionHTTP.Header.Set("Content-Type", "application/json") + collectionHTTP.Header.Set("Authorization", "Bearer "+setup.Token) + + collectionResp, err := client.Do(collectionHTTP) + require.NoError(t, err) + defer collectionResp.Body.Close() + require.Equal(t, http.StatusCreated, collectionResp.StatusCode) + + var collectionResult map[string]interface{} + json.NewDecoder(collectionResp.Body).Decode(&collectionResult) + collectionID := collectionResult["id"].(string) + + // Create a test book via API + bookID := createTestMediaItemID(t, setup) + + // Connect admin user via WebSocket + wsAdmin := connectWebSocketToServer(t, setup.Server.URL, setup.Token) + defer wsAdmin.Close() + + // Connect regular user via WebSocket + wsRegular := connectWebSocketToServer(t, setup.Server.URL, setup.RegularToken) + defer wsRegular.Close() + + // Admin adds book to collection + addReq := map[string]interface{}{ + "book_ids": []string{bookID}, + } + addBody, _ := json.Marshal(addReq) + + addHTTP, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/books", bytes.NewBuffer(addBody)) + addHTTP.Header.Set("Content-Type", "application/json") + addHTTP.Header.Set("Authorization", "Bearer "+setup.Token) + + addResp, err := client.Do(addHTTP) + require.NoError(t, err) + defer addResp.Body.Close() + require.Equal(t, http.StatusNoContent, addResp.StatusCode) + + // Admin should receive collection_updated message + msgAdmin := readWebSocketMessage(t, wsAdmin, 2*time.Second) + if msgAdmin["type"] != "collection_updated" { + t.Errorf("Admin should receive collection_updated, got %s", msgAdmin["type"]) + } + + // Regular user should NOT receive collection_updated message + wsRegular.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, _, err = wsRegular.ReadMessage() + if err == nil { + t.Errorf("Regular user should not receive collection_updated message") + } +} + +// Helper: connectWebSocketToServer establishes WebSocket connection with auth token +func connectWebSocketToServer(t *testing.T, serverURL string, token string) *websocket.Conn { + wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + "/ws/sync?token=" + token + + ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err, "WebSocket connection should succeed") + if resp != nil { + resp.Body.Close() + } + require.NotNil(t, ws, "WebSocket connection should be established") + + // Wait for connection to be ready + time.Sleep(100 * time.Millisecond) + + return ws +} + +// Helper: readWebSocketMessage reads a message from WebSocket with timeout +func readWebSocketMessage(t *testing.T, ws *websocket.Conn, timeout time.Duration) map[string]interface{} { + ws.SetReadDeadline(time.Now().Add(timeout)) + + _, message, err := ws.ReadMessage() + require.NoError(t, err, "Should receive WebSocket message") + + var msg map[string]interface{} + err = json.Unmarshal(message, &msg) + require.NoError(t, err, "Message should be valid JSON") + + return msg +} diff --git a/docs/developer/api/media-items/search_media_items.md b/docs/developer/api/media-items/search_media_items.md index b797ccc..7037541 100644 --- a/docs/developer/api/media-items/search_media_items.md +++ b/docs/developer/api/media-items/search_media_items.md @@ -15,11 +15,12 @@ Examples: ## Query Parameters -| Parameter | Type | Required | Description | -| --------- | ------- | -------- | ----------------------------------- | -| q | string | Yes | Search query (minimum 2 characters) | -| limit | integer | No | Number of results (default 20) | -| offset | integer | No | Number to skip | +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------------- | +| q | string | Yes | Search query (minimum 2 characters) | +| library_id| string | No | Filter results to specific library (UUID) | +| limit | integer | No | Number of results (default 20) | +| offset | integer | No | Number to skip | ## Request Headers @@ -29,11 +30,20 @@ Examples: ### Example Request +Search all libraries: + ```http GET /api/media-items/search?q=Harry+Potter&limit=20&offset=0 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` +Search within a specific library: + +```http +GET /api/media-items/search?q=Harry&library_id=123e4567-e89b-12d3-a456-426614174000&limit=20 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + ## Response (200 OK) ```json @@ -43,6 +53,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... "id": "uuid", "title": "Book Title", "author": "Author Name", + "library_id": "...", + "library_name": "E-Books", "match_score": 0.95 } ], diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 4de31c7..fc01487 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -6931,27 +6931,29 @@ JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 WHERE COALESCE(lv.is_visible, true) = true + AND ($2 IS NULL OR mi.library_id = $2) AND ( - mi.title ILIKE $2 OR - mi.author ILIKE $2 OR - mi.series ILIKE $2 OR - $2 = ANY(mi.tags_search) OR - $2 = ANY(mi.contributors_search) + mi.title ILIKE $3 OR + mi.author ILIKE $3 OR + mi.series ILIKE $3 OR + $3 = ANY(mi.tags_search) OR + $3 = ANY(mi.contributors_search) ) ORDER BY CASE - WHEN mi.title ILIKE $2 THEN 1 - WHEN mi.author ILIKE $2 THEN 2 - WHEN mi.series ILIKE $2 THEN 3 - WHEN $2 = ANY(mi.tags_search) THEN 4 + WHEN mi.title ILIKE $3 THEN 1 + WHEN mi.author ILIKE $3 THEN 2 + WHEN mi.series ILIKE $3 THEN 3 + WHEN $3 = ANY(mi.tags_search) THEN 4 ELSE 5 END, mi.title ASC -LIMIT $4 OFFSET $3 +LIMIT $5 OFFSET $4 ` type SearchMediaItemsParams struct { UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID interface{} `db:"library_id" json:"library_id"` SearchPattern pgtype.Text `db:"search_pattern" json:"search_pattern"` Offset pgtype.Int4 `db:"offset" json:"offset"` Limit pgtype.Int4 `db:"limit" json:"limit"` @@ -7010,6 +7012,7 @@ type SearchMediaItemsRow struct { func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error) { rows, err := q.db.Query(ctx, SearchMediaItems, arg.UserID, + arg.LibraryID, arg.SearchPattern, arg.Offset, arg.Limit, @@ -7086,43 +7089,45 @@ JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 WHERE COALESCE(lv.is_visible, true) = true + AND ($2 IS NULL OR mi.library_id = $2) AND ( - word_similarity($2, mi.title) > 0.3 OR - word_similarity($2, COALESCE(mi.author, '')) > 0.3 OR - word_similarity($2, COALESCE(mi.series, '')) > 0.3 OR + word_similarity($3, mi.title) > 0.3 OR + word_similarity($3, COALESCE(mi.author, '')) > 0.3 OR + word_similarity($3, COALESCE(mi.series, '')) > 0.3 OR EXISTS ( SELECT 1 FROM unnest(mi.tags_search) AS tag - WHERE word_similarity($2, tag) > 0.3 + WHERE word_similarity($3, tag) > 0.3 LIMIT 1 ) OR EXISTS ( SELECT 1 FROM unnest(mi.contributors_search) AS contributor - WHERE word_similarity($2, contributor) > 0.3 + WHERE word_similarity($3, contributor) > 0.3 LIMIT 1 ) ) ORDER BY GREATEST( - word_similarity($2, mi.title), - word_similarity($2, COALESCE(mi.author, '')), - word_similarity($2, COALESCE(mi.series, '')), + word_similarity($3, mi.title), + word_similarity($3, COALESCE(mi.author, '')), + word_similarity($3, COALESCE(mi.series, '')), COALESCE( - (SELECT MAX(word_similarity($2, tag)) + (SELECT MAX(word_similarity($3, tag)) FROM unnest(mi.tags_search) AS tag), 0 ), COALESCE( - (SELECT MAX(word_similarity($2, contributor)) + (SELECT MAX(word_similarity($3, contributor)) FROM unnest(mi.contributors_search) AS contributor), 0 ) ) DESC, mi.title ASC - LIMIT $4 OFFSET $3 + LIMIT $5 OFFSET $4 ` type SearchMediaItemsFuzzyParams struct { UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID interface{} `db:"library_id" json:"library_id"` SearchQuery interface{} `db:"search_query" json:"search_query"` Offset pgtype.Int4 `db:"offset" json:"offset"` Limit pgtype.Int4 `db:"limit" json:"limit"` @@ -7180,6 +7185,7 @@ type SearchMediaItemsFuzzyRow struct { func (q *Queries) SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error) { rows, err := q.db.Query(ctx, SearchMediaItemsFuzzy, arg.UserID, + arg.LibraryID, arg.SearchQuery, arg.Offset, arg.Limit, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index a4cbc87..ed92681 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -397,6 +397,7 @@ JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') WHERE COALESCE(lv.is_visible, true) = true + AND (sqlc.narg('library_id') IS NULL OR mi.library_id = sqlc.narg('library_id')) AND ( mi.title ILIKE sqlc.narg('search_pattern') OR mi.author ILIKE sqlc.narg('search_pattern') OR @@ -422,6 +423,7 @@ JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') WHERE COALESCE(lv.is_visible, true) = true + AND (sqlc.narg('library_id') IS NULL OR mi.library_id = sqlc.narg('library_id')) AND ( word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 59aad7f..084bb14 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -330,7 +330,7 @@ func (h *CollectionHandler) AddBooks(c echo.Context) error { } if addedCount > 0 && h.connManager != nil { - h.connManager.Broadcast(wsync.BroadcastMessage{ + h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{ Type: "collection_updated", Timestamp: time.Now().Format(time.RFC3339), Data: map[string]interface{}{ @@ -374,6 +374,9 @@ func (h *CollectionHandler) BulkRemoveBooks(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + var req BulkRemoveBooksRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) @@ -398,7 +401,7 @@ func (h *CollectionHandler) BulkRemoveBooks(c echo.Context) error { } if removedCount > 0 && h.connManager != nil { - h.connManager.Broadcast(wsync.BroadcastMessage{ + h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{ Type: "collection_updated", Timestamp: time.Now().Format(time.RFC3339), Data: map[string]interface{}{ @@ -753,6 +756,7 @@ func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) // Bulk add books to multiple collections func (h *CollectionHandler) HandleBulkAddBooks(c echo.Context) error { user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) var req struct { Operations []struct { @@ -828,7 +832,7 @@ func (h *CollectionHandler) HandleBulkAddBooks(c echo.Context) error { } if successCount > 0 && h.connManager != nil { - h.connManager.Broadcast(wsync.BroadcastMessage{ + h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{ Type: "collection_updated", Timestamp: time.Now().Format(time.RFC3339), Data: map[string]interface{}{ diff --git a/internal/handlers/media.go b/internal/handlers/media.go index a484096..10a31ba 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1419,6 +1419,7 @@ func (mh *MediaHandler) DeleteMediaHighlight(c echo.Context) error { func (mh *MediaHandler) SearchMediaItems(c echo.Context) error { query := c.QueryParam("q") userID := c.Get("user_id").(string) + libraryID := c.QueryParam("library_id") if query == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"}) @@ -1429,17 +1430,31 @@ func (mh *MediaHandler) SearchMediaItems(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } + // Validate library_id if provided + var libUUID pgtype.UUID + if libraryID != "" { + lib, err := uuid.Parse(libraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } + libUUID = pgtype.UUID{Bytes: lib, Valid: true} + } + limit := int32(50) offset := int32(0) searchPattern := "%" + query + "%" - partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ + // Build params - conditionally and library_id filter + partialParams := database.SearchMediaItemsParams{ SearchPattern: pgtype.Text{String: searchPattern, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, - }) + LibraryID: libUUID, // May be invalid (empty) + } + + partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), partialParams) if err != nil && err != pgx.ErrNoRows { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -1449,12 +1464,15 @@ func (mh *MediaHandler) SearchMediaItems(c echo.Context) error { return c.JSON(http.StatusOK, partialResults) } - fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), database.SearchMediaItemsFuzzyParams{ + fuzzyParams := database.SearchMediaItemsFuzzyParams{ SearchQuery: pgtype.Text{String: query, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, - }) + LibraryID: libUUID, + } + + fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), fuzzyParams) if err != nil && err != pgx.ErrNoRows { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 6fac606..f0b721f 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -427,9 +427,12 @@ func registerFrontendRoutes(cfg *Config) { Color: collection.Color.String, Icon: collection.Icon.String, } + + // Get library_id from query params for template + libraryID := c.QueryParam("library_id") // Render the CollectionDetail template var buf bytes.Buffer - err = templates.CollectionDetail(user, colData, books).Render(c.Request().Context(), &buf) + err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Request().Context(), &buf) if err != nil { return err } diff --git a/internal/sync/websocket.go b/internal/sync/websocket.go index ef90c89..8494c46 100644 --- a/internal/sync/websocket.go +++ b/internal/sync/websocket.go @@ -93,6 +93,23 @@ func (m *ConnectionManager) Broadcast(msg BroadcastMessage) { } } +// BroadcastToUser sends a message to all connections for a specific user +func (m *ConnectionManager) BroadcastToUser(userID string, msg BroadcastMessage) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, conn := range m.connections { + if conn.UserID == userID { + select { + case conn.Send <- msg: + default: + // Channel full, skip this connection + log.Printf("WebSocket: Channel full for %s, skipping broadcast", conn.DeviceName) + } + } + } +} + // BroadcastProgressUpdate broadcasts a progress update to all connected clients func (m *ConnectionManager) BroadcastProgressUpdate(bookID uuid.UUID, percentage float64, source SourceDevice) { msg := BroadcastMessage{ diff --git a/templates/collections.templ b/templates/collections.templ index d4ec98b..ca40295 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -102,7 +102,7 @@ templ Collection(user User, collections []CollectionData, errorMessage string) { } -templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) { +templ CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string) { @@ -232,6 +232,13 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo

Search and select books to add to this collection.

+ +
- + + + } diff --git a/templates/collections_templ.go b/templates/collections_templ.go index 845ddc5..d068451 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -162,7 +162,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te }) } -func CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo) templ.Component { +func CollectionDetail(user User, collection CollectionData, books []handlers.BookInfo, libraryID string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -323,7 +323,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Add Books to Collection

Search and select books to add to this collection.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Add Books to Collection

Search and select books to add to this collection.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/collections.ts b/web/src/collections.ts index f728938..1a3295d 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -474,3 +474,457 @@ function initIconSelection(): void { (window as any).showAllIcons = showAllIcons; (window as any).populateIconGrid = populateIconGrid; (window as any).initIconSelection = initIconSelection; + +// ============================================================================ +// Collection Detail Page - TypeScript with WebSocket Support +// ============================================================================ + +interface SearchBookResult { + media_item_id: string; + title: string; + author: string | null; + cover_image_path: string | null; + library_id: string; + library_name: string; +} + +interface CollectionUpdateMessage { + type: string; + data: { + collection_id: string; + action: string; + count?: number; + book_id?: string; + }; +} + +let collectionId = ""; +let libraryId = ""; +let booksToAdd = new Set(); +let booksToRemove = new Set(); +let ws: WebSocket | null = null; + +// Initialize from data attributes (called on page load) +function initCollectionDetail(): void { + const dataEl = document.getElementById("collection-data"); + if (dataEl) { + collectionId = dataEl.dataset.id || ""; + libraryId = dataEl.dataset.libraryId || ""; + + // Show/hide library filter toggle based on whether library_id is present + const filterContainer = document.getElementById("library-filter-container"); + if (filterContainer) { + if (libraryId) { + filterContainer.classList.remove("hidden"); + // Default: checked (filter by library) + const checkbox = document.getElementById( + "filter-by-library", + ) as HTMLInputElement; + if (checkbox) checkbox.checked = true; + } else { + filterContainer.classList.add("hidden"); + } + } + } + + // Initialize WebSocket connection + connectWebSocket(); +} + +// Get current library filter setting +function getLibraryFilterParam(): string { + if (!libraryId) return ""; + + const checkbox = document.getElementById( + "filter-by-library", + ) as HTMLInputElement; + if (checkbox && checkbox.checked) { + return `&library_id=${libraryId}`; + } + return ""; +} + +// WebSocket connection for real-time collection updates +function connectWebSocket(): void { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const token = localStorage.getItem("token"); + if (!token) return; + + const wsUrl = `${protocol}//${window.location.host}/ws/sync?token=${token}`; + + ws = new WebSocket(wsUrl); + + ws.onopen = (): void => { + console.log("WebSocket connected"); + }; + + ws.onmessage = (event: MessageEvent): void => { + try { + const message = JSON.parse(event.data) as CollectionUpdateMessage; + + if ( + message.type === "collection_updated" && + message.data.collection_id === collectionId + ) { + const actionText = + message.data.action === "books_added" + ? `Added ${message.data.count || 0} book(s)` + : message.data.action === "book_removed" + ? "Removed a book" + : message.data.action === "books_bulk_removed" + ? `Removed ${message.data.count || 0} book(s)` + : "Collection updated"; + + (window as any).showToast?.(actionText, "info"); + + // Mitigation: Skip auto-reload if user is actively typing or interacting + const activeElement = document.activeElement; + const isUserActive = + activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.tagName === "SELECT" || + activeElement.getAttribute("contenteditable") === "true"); + + if (!isUserActive) { + // Auto-reload after 1 second to see updates (only if user not actively typing) + setTimeout(() => { + location.reload(); + }, 1000); + } else { + // User is active - just show toast, don't reload + // They'll see updates when they navigate away or manually refresh + console.log("User actively typing - skipping auto-reload"); + } + } + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + ws.onclose = (): void => { + console.log("WebSocket disconnected, reconnecting in 5s..."); + setTimeout(connectWebSocket, 5000); + }; + + ws.onerror = (error: Event): void => { + console.error("WebSocket error:", error); + }; +} + +function backToCollections(): void { + window.location.href = "/collections"; +} + +// Modal functions (called from onclick attributes) +function showAddBooksModal(): void { + const modal = document.getElementById("add-books-modal"); + if (modal) modal.classList.remove("hidden"); + booksToAdd.clear(); + + const results = document.getElementById("book-results"); + if (results) { + results.innerHTML = + '

Enter at least 2 characters to search.

'; + } +} + +function hideAddBooksModal(): void { + const modal = document.getElementById("add-books-modal"); + if (modal) modal.classList.add("hidden"); + + const searchInput = document.getElementById( + "book-search", + ) as HTMLInputElement; + if (searchInput) searchInput.value = ""; + + const results = document.getElementById("book-results"); + if (results) results.innerHTML = ""; + + booksToAdd.clear(); +} + +// Search books - includes library filter +async function searchBooksForCollections(): Promise { + const searchInput = document.getElementById( + "book-search", + ) as HTMLInputElement; + const container = document.getElementById("book-results"); + if (!searchInput || !container) return; + + const searchTerm = searchInput.value; + if (searchTerm.length < 2) { + container.innerHTML = + '

Enter at least 2 characters to search.

'; + return; + } + + container.innerHTML = + '

Searching...

'; + + const libraryFilter = getLibraryFilterParam(); + const token = localStorage.getItem("token"); + if (!token) { + container.innerHTML = + '

Authentication required

'; + return; + } + + try { + const response = await fetch( + `/api/media-items/search?q=${encodeURIComponent(searchTerm)}${libraryFilter}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const result = (await response.json()) as SearchBookResult[]; + + if (result.length > 0) { + let html = '
'; + result.slice(0, 50).forEach((book) => { + const isSelected = booksToAdd.has(book.media_item_id); + const checkedAttr = isSelected ? "checked" : ""; + const authorHtml = book.author + ? `
${book.author}
` + : ""; + const libraryBadge = + book.library_id === libraryId + ? 'This Library' + : ""; + + html += ` +
+ + Cover +
+
${book.title}
+ ${authorHtml} +
+ ${libraryBadge} +
+ `; + }); + html += "
"; + container.innerHTML = html; + } else { + container.innerHTML = + '

No books found

'; + } + } catch (error) { + console.error("Search error:", error); + container.innerHTML = + '

Failed to search books

'; + } +} + +// Toggle book selection +function toggleBookSelection(bookId: string): void { + if (booksToAdd.has(bookId)) { + booksToAdd.delete(bookId); + } else { + booksToAdd.add(bookId); + } + searchBooksForCollections(); +} + +// Add selected books to collection +async function addbooksToAdd(): Promise { + if (booksToAdd.size === 0) { + (window as any).showToast?.("Please select at least one book", "error"); + return; + } + + const bookIds = Array.from(booksToAdd); + const token = localStorage.getItem("token"); + if (!token) { + (window as any).showToast?.("Authentication required", "error"); + return; + } + + try { + const response = await fetch(`/api/collections/${collectionId}/books`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ book_ids: bookIds }), + }); + + if (response.ok) { + (window as any).showToast?.( + `Added ${bookIds.length} book(s) to collection`, + "success", + ); + hideAddBooksModal(); + // Note: WebSocket will trigger page reload automatically + } else { + (window as any).showToast?.("Failed to add books", "error"); + } + } catch (error) { + console.error("Add books error:", error); + (window as any).showToast?.("Failed to add books", "error"); + } +} + +// Remove single book from collection +async function removeBook(bookId: string): Promise { + if (!confirm("Remove this book from the collection?")) return; + + const token = localStorage.getItem("token"); + if (!token) { + (window as any).showToast?.("Authentication required", "error"); + return; + } + + try { + const response = await fetch( + `/api/collections/${collectionId}/books/${bookId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (response.ok) { + (window as any).showToast?.("Book removed from collection", "success"); + // Note: WebSocket will trigger page reload automatically + } else { + (window as any).showToast?.("Failed to remove book", "error"); + } + } catch (error) { + console.error("Remove book error:", error); + (window as any).showToast?.("Failed to remove book", "error"); + } +} + +// Bulk remove functions +function toggleBookForRemoval(bookId: string): void { + if (booksToRemove.has(bookId)) { + booksToRemove.delete(bookId); + } else { + booksToRemove.add(bookId); + } + updateSelectedCount(); +} + +function updateSelectedCount(): void { + const count = booksToRemove.size; + const countSpan = document.getElementById("selected-count"); + const removeBtn = document.getElementById( + "bulk-remove-btn", + ) as HTMLButtonElement; + + if (count > 0) { + if (countSpan) { + countSpan.textContent = `${count} selected`; + countSpan.classList.remove("hidden"); + } + if (removeBtn) removeBtn.disabled = false; + } else { + if (countSpan) countSpan.classList.add("hidden"); + if (removeBtn) removeBtn.disabled = true; + } +} + +async function removebooksToAdd(): Promise { + if (booksToRemove.size === 0) { + (window as any).showToast?.("No books selected", "error"); + return; + } + + if (!confirm(`Remove ${booksToRemove.size} book(s) from the collection?`)) + return; + + const bookIds = Array.from(booksToRemove); + const token = localStorage.getItem("token"); + if (!token) { + (window as any).showToast?.("Authentication required", "error"); + return; + } + + try { + const response = await fetch( + `/api/collections/${collectionId}/books/bulk-remove`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ book_ids: bookIds }), + }, + ); + + if (response.ok) { + const result = (await response.json()) as { removed: number }; + if (result.removed > 0) { + (window as any).showToast?.( + `Removed ${result.removed} book(s) from collection`, + "success", + ); + // Note: WebSocket will trigger page reload automatically + } else { + (window as any).showToast?.("Failed to remove books", "error"); + } + } else { + (window as any).showToast?.("Failed to remove books", "error"); + } + } catch (error) { + console.error("Bulk remove error:", error); + (window as any).showToast?.("Failed to remove books", "error"); + } +} + +// Client-side search filter for displayed books +function filterCollectionBooks(): void { + const searchTerm = + ( + document.getElementById("collection-search") as HTMLInputElement + )?.value.toLowerCase() || ""; + const booksContainer = document.getElementById("books-container"); + if (!booksContainer) return; + + const bookCards = booksContainer.children; + for (let i = 0; i < bookCards.length; i++) { + const card = bookCards[i] as HTMLElement; + if (card.id === "empty-state") continue; + + const titleEl = card.querySelector(".font-semibold"); + const authorEl = card.querySelector(".text-sm"); + const title = titleEl?.textContent?.toLowerCase() || ""; + const author = authorEl?.textContent?.toLowerCase() || ""; + + const matches = title.includes(searchTerm) || author.includes(searchTerm); + card.style.display = matches || searchTerm === "" ? "" : "none"; + } +} + +// Export functions globally +(window as any).initCollectionDetail = initCollectionDetail; +(window as any).showAddBooksModal = showAddBooksModal; +(window as any).hideAddBooksModal = hideAddBooksModal; +(window as any).searchBooksForCollections = searchBooksForCollections; +(window as any).toggleBookSelection = toggleBookSelection; +(window as any).addbooksToAdd = addbooksToAdd; +(window as any).removeBook = removeBook; +(window as any).toggleBookForRemoval = toggleBookForRemoval; +(window as any).updateSelectedCount = updateSelectedCount; +(window as any).removebooksToAdd = removebooksToAdd; +(window as any).filterCollectionBooks = filterCollectionBooks; +(window as any).backToCollections = backToCollections; + +// Auto-initialize +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initCollectionDetail); +} else { + initCollectionDetail(); +}