refactor(bruno): migrate API tests to Bruno DSL format

- Convert all existing .bru files from JSON to Bruno DSL format
- Remove obsolete files (conflicts/api.bru, kobo/Kobo Initialization.bru)
- Update auth configuration to use 'inherit' instead of explicit bearer tokens
- Add comprehensive documentation to all test files
- Improve test scripts with proper assertions and error handling
This commit is contained in:
2026-02-08 20:49:06 -05:00
parent cb76b9ca05
commit 3117ce54ec
93 changed files with 3974 additions and 1576 deletions
+83 -30
View File
@@ -1,35 +1,88 @@
{
"meta": {
"name": "Get Device Usage",
"type": "http",
"event": [
meta {
name: Get Device Usage
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/analytics/device-usage
body: none
auth: inherit
}
tests {
test("status must be 200", function() {
expect(res.status).to.eql(200);
});
test("response has devices array", function() {
const body = JSON.parse(res.body);
expect(body).to.have.property("devices");
expect(body.devices).to.be.an("array");
});
test("devices have required fields", function() {
const body = JSON.parse(res.body);
if (body.devices.length > 0) {
expect(body.devices[0]).to.have.property("device_name");
expect(body.devices[0]).to.have.property("device_type");
expect(body.devices[0]).to.have.property("sync_count");
}
});
}
settings {
encodeUrl: true
timeout: 0
}
docs {
Get usage statistics for all devices.
**Endpoint**: GET /api/analytics/device-usage
**Auth**: Required (Bearer token)
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| devices | array | List of device usage statistics |
| devices[].id | string | Device ID |
| devices[].device_name | string | Device name |
| devices[].device_type | string | Device type (kobo, kindle, koreader) |
| devices[].sync_count | int | Number of sync operations |
| devices[].last_sync | string | Last sync timestamp |
| devices[].total_reading_minutes | int | Total reading time on device |
| devices[].books_read | int | Number of books completed on device |
## Example Response
```json
{
"devices": [
{
"listen": "test",
"script": {
"exec": [
"// Test device usage response",
"if (response.status === 200) {",
" tests['Device usage loaded'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has devices array'] = Array.isArray(body.devices);",
" if (body.devices.length > 0) {",
" tests['Device has device_name'] = body.devices[0].device_name !== undefined;",
" tests['Device has device_type'] = body.devices[0].device_type !== undefined;",
" tests['Device has sync_count'] = body.devices[0].sync_count !== undefined;",
" }",
"} else {",
" tests['Device usage failed'] = false;",
"}"
]
}
"id": "789e4567-e89b-12d3-a456-426614174001",
"device_name": "My Kobo Clara",
"device_type": "kobo",
"sync_count": 45,
"last_sync": "2026-02-08T17:25:00Z",
"total_reading_minutes": 1250,
"books_read": 3
}
]
},
"req": {
"url": "{{baseUrl}}/api/analytics/device-usage",
"method": "GET",
"headers": {
"Authorization": "Bearer {{authToken}}"
}
}
```
## Error Responses
| Code | Description |
|------|-------------|
| 401 | Unauthorized |
| 500 | Internal server error |
## Notes
- Only shows devices registered to the authenticated user
- Devices are sorted by sync_count in descending order
- Includes both active and inactive devices
}
+94 -31
View File
@@ -1,36 +1,99 @@
{
"meta": {
"name": "Get Popular Books",
"type": "http",
"event": [
meta {
name: Get Popular Books
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/analytics/popular-books?limit=10
body: none
auth: inherit
}
tests {
test("status must be 200", function() {
expect(res.status).to.eql(200);
});
test("response has books array", function() {
const body = JSON.parse(res.body);
expect(body).to.have.property("books");
expect(body.books).to.be.an("array");
});
test("books have required fields", function() {
const body = JSON.parse(res.body);
if (body.books.length > 0) {
expect(body.books[0]).to.have.property("title");
expect(body.books[0]).to.have.property("author");
expect(body.books[0]).to.have.property("read_count");
expect(body.books[0]).to.have.property("avg_completion");
}
});
}
settings {
encodeUrl: true
timeout: 0
}
docs {
Get popular books sorted by read count.
**Endpoint**: GET /api/analytics/popular-books
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| limit | int | No | Maximum number of books to return (default: 10) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| books | array | List of popular books |
| books[].media_item_id | string | Book ID |
| books[].title | string | Book title |
| books[].author | string | Book author |
| books[].read_count | int | Number of times read |
| books[].avg_completion | float | Average completion rate (0-1) |
| books[].cover_url | string | Cover image URL |
## Example Request
```
GET /api/analytics/popular-books?limit=10
```
## Example Response
```json
{
"books": [
{
"listen": "test",
"script": {
"exec": [
"// Test popular books response",
"if (response.status === 200) {",
" tests['Popular books loaded'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has books array'] = Array.isArray(body.books);",
" if (body.books.length > 0) {",
" tests['Book has title'] = body.books[0].title !== undefined;",
" tests['Book has author'] = body.books[0].author !== undefined;",
" tests['Book has read_count'] = body.books[0].read_count !== undefined;",
" tests['Book has avg_completion'] = body.books[0].avg_completion !== undefined;",
" }",
"} else {",
" tests['Popular books failed'] = false;",
"}"
]
}
"media_item_id": "323e4567-e89b-12d3-a456-426614174002",
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"read_count": 5,
"avg_completion": 0.85,
"cover_url": "/api/books/323e4567-e89b-12d3-a456-426614174002/cover"
}
]
},
"req": {
"url": "{{baseUrl}}/api/analytics/popular-books?limit=10",
"method": "GET",
"headers": {
"Authorization": "Bearer {{authToken}}"
}
}
```
## Error Responses
| Code | Description |
|------|-------------|
| 401 | Unauthorized |
| 500 | Internal server error |
## Notes
- Books are sorted by read_count in descending order
- Only books owned by the authenticated user are included
- avg_completion is calculated from all reading sessions
}
@@ -1,30 +1,95 @@
{
"meta": {
"name": "Get Reading Stats with Date Range",
"type": "http",
"event": [
meta {
name: Get Reading Stats Date Range
type: http
seq: 3
}
get {
url: {{baseUrl}}/api/analytics/reading-stats?start_date=2024-01-01&end_date=2024-01-31
body: none
auth: inherit
}
tests {
test("status must be 200", function() {
expect(res.status).to.eql(200);
});
test("response has daily_reading_minutes array", function() {
const body = JSON.parse(res.body);
expect(body).to.have.property("daily_reading_minutes");
expect(body.daily_reading_minutes).to.be.an("array");
});
}
settings {
encodeUrl: true
timeout: 0
}
docs {
Get reading statistics for a specific date range.
**Endpoint**: GET /api/analytics/reading-stats
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| start_date | string | No | Start date (ISO 8601 format, default: 30 days ago) |
| end_date | string | No | End date (ISO 8601 format, default: today) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| total_books_read | int | Total books completed in range |
| total_pages_read | int | Total pages read in range |
| total_reading_time_minutes | int | Total reading time in minutes |
| completion_rate | float | Percentage of books completed (0-1) |
| daily_reading_minutes | array | Daily reading time per day |
| daily_reading_minutes[].date | string | Date (ISO 8601) |
| daily_reading_minutes[].minutes | int | Minutes read on that date |
## Example Request
```
GET /api/analytics/reading-stats?start_date=2024-01-01&end_date=2024-01-31
```
## Example Response
```json
{
"total_books_read": 2,
"total_pages_read": 450,
"total_reading_time_minutes": 720,
"completion_rate": 0.85,
"daily_reading_minutes": [
{
"listen": "test",
"script": {
"exec": [
"// Test reading stats with custom date range",
"if (response.status === 200) {",
" tests['Date range stats loaded'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has daily_reading_minutes'] = Array.isArray(body.daily_reading_minutes);",
"} else {",
" tests['Date range stats failed'] = false;",
"}"
]
}
"date": "2024-01-01",
"minutes": 30
},
{
"date": "2024-01-02",
"minutes": 45
}
]
},
"req": {
"url": "{{baseUrl}}/api/analytics/reading-stats?start_date=2024-01-01&end_date=2024-01-31",
"method": "GET",
"headers": {
"Authorization": "Bearer {{authToken}}"
}
}
```
## Error Responses
| Code | Description |
|------|-------------|
| 400 | Invalid date format |
| 401 | Unauthorized |
| 500 | Internal server error |
## Notes
- Dates must be in ISO 8601 format (YYYY-MM-DD)
- The range is inclusive of both start and end dates
- Daily data only includes days with reading activity > 0
}
+99 -29
View File
@@ -1,34 +1,104 @@
{
"meta": {
"name": "Get Reading Stats",
"type": "http",
"event": [
meta {
name: Get Reading Stats
type: http
seq: 4
}
get {
url: {{baseUrl}}/api/analytics/reading-stats
body: none
auth: inherit
}
tests {
test("status must be 200", function() {
expect(res.status).to.eql(200);
});
test("response has required stats fields", function() {
const body = JSON.parse(res.body);
expect(body).to.have.property("total_books_read");
expect(body).to.have.property("total_pages_read");
expect(body).to.have.property("total_reading_time_minutes");
expect(body).to.have.property("completion_rate");
expect(body).to.have.property("daily_reading_minutes");
});
test("daily_reading_minutes is an array", function() {
const body = JSON.parse(res.body);
expect(body.daily_reading_minutes).to.be.an("array");
});
}
settings {
encodeUrl: true
timeout: 0
}
docs {
Get overall reading statistics for the authenticated user.
**Endpoint**: GET /api/analytics/reading-stats
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| start_date | string | No | Start date (ISO 8601 format) |
| end_date | string | No | End date (ISO 8601 format) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| total_books_read | int | Total books completed |
| total_pages_read | int | Total pages read |
| total_reading_time_minutes | int | Total reading time in minutes |
| completion_rate | float | Average book completion rate (0-1) |
| daily_reading_minutes | array | Daily reading time breakdown |
| daily_reading_minutes[].date | string | Date (ISO 8601) |
| daily_reading_minutes[].minutes | int | Minutes read on that date |
## Example Request
```
GET /api/analytics/reading-stats
```
## Example Response
```json
{
"total_books_read": 12,
"total_pages_read": 3450,
"total_reading_time_minutes": 5400,
"completion_rate": 0.78,
"daily_reading_minutes": [
{
"listen": "test",
"script": {
"exec": [
"// Test reading stats response",
"if (response.status === 200) {",
" tests['Reading stats loaded'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has total_books_read'] = body.total_books_read !== undefined;",
" tests['Has total_pages_read'] = body.total_pages_read !== undefined;",
" tests['Has total_reading_time_minutes'] = body.total_reading_time_minutes !== undefined;",
" tests['Has completion_rate'] = body.completion_rate !== undefined;",
" tests['Has daily_reading_minutes array'] = Array.isArray(body.daily_reading_minutes);",
"} else {",
" tests['Reading stats failed'] = false;",
"}"
]
}
"date": "2026-01-15",
"minutes": 45
},
{
"date": "2026-01-16",
"minutes": 60
}
]
},
"req": {
"url": "{{baseUrl}}/api/analytics/reading-stats",
"method": "GET",
"headers": {
"Authorization": "Bearer {{authToken}}"
}
}
```
## Error Responses
| Code | Description |
|------|-------------|
| 400 | Invalid date format |
| 401 | Unauthorized |
| 500 | Internal server error |
## Notes
- Without date parameters, returns stats for the last 30 days
- Dates must be in ISO 8601 format (YYYY-MM-DD) when provided
- Only includes reading activity from the authenticated user
- Daily data includes all days with reading activity
}
+82 -38
View File
@@ -1,41 +1,85 @@
{
"meta": {
"name": "Bulk Delete Books",
"type": "http",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Test bulk delete response",
"if (response.status === 200) {",
" tests['Bulk delete successful'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has results array'] = Array.isArray(body.results);",
" tests['Has total count'] = body.total !== undefined;",
" tests['Has success count'] = body.success !== undefined;",
" tests['Has failed count'] = body.failed !== undefined;",
"} else {",
" tests['Bulk delete failed'] = false;",
"}"
]
}
}
meta {
name: Bulk Delete Books
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/books/bulk-delete
body: json
auth: inherit
}
headers {
Content-Type: application/json
}
body:json {
{
"book_ids": [
"{{bookId1}}",
"{{bookId2}}",
"{{bookId3}}"
]
},
"req": {
"url": "{{baseUrl}}/api/books/bulk-delete",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{authToken}}"
},
"body": {
"book_ids": [
"{{bookId1}}",
"{{bookId2}}",
"{{bookId3}}"
]
}
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Bulk delete successful'] = true;
const body = res.getBody();
tests['Has results array'] = Array.isArray(body.results);
tests['Has total count'] = body.total !== undefined;
tests['Has success count'] = body.success !== undefined;
tests['Has failed count'] = body.failed !== undefined;
} else {
tests['Bulk delete failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Delete Books
Deletes multiple books in a single request.
**Method:** POST
**Endpoint:** /api/books/bulk-delete
**Authentication:** Required (Bearer token)
**Request Body:**
- `book_ids` (array of strings): Array of book UUIDs to delete
**Response:**
- `results` (array): Results for each deletion attempt
- `total` (number): Total number of books processed
- `success` (number): Number of successfully deleted books
- `failed` (number): Number of failed deletions
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Example:**
```json
{
"book_ids": [
"uuid-1",
"uuid-2",
"uuid-3"
]
}
```
}
+106 -46
View File
@@ -1,52 +1,112 @@
{
"meta": {
"name": "Bulk Update Books",
"type": "http",
"event": [
meta {
name: Bulk Update Books
type: http
seq: 2
}
post {
url: {{baseUrl}}/api/books/bulk-update
body: json
auth: inherit
}
headers {
Content-Type: application/json
}
body:json {
{
"updates": [
{
"listen": "test",
"script": {
"exec": [
"// Test bulk update response",
"if (response.status === 200) {",
" tests['Bulk update successful'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has results array'] = Array.isArray(body.results);",
" tests['Has total count'] = body.total !== undefined;",
" tests['Has success count'] = body.success !== undefined;",
"} else {",
" tests['Bulk update failed'] = false;",
"}"
]
"book_id": "{{bookId1}}",
"updates": {
"title": "Updated Title",
"genre": "Science Fiction",
"tags": ["science fiction", "non-fiction", "ACME CORP."]
}
},
{
"book_id": "{{bookId2}}",
"updates": {
"author": "Updated Author",
"contributors": ["O'Reilly Media", "Penguin Random House"]
}
}
]
},
"req": {
"url": "{{baseUrl}}/api/books/bulk-update",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{authToken}}"
},
"body": {
"updates": [
{
"book_id": "{{bookId1}}",
"updates": {
"title": "Updated Title",
"genre": "Science Fiction",
"tags": ["science fiction", "non-fiction", "ACME CORP."]
}
},
{
"book_id": "{{bookId2}}",
"updates": {
"author": "Updated Author",
"contributors": ["O'Reilly Media", "Penguin Random House"]
}
}
]
}
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Bulk update successful'] = true;
const body = res.getBody();
tests['Has results array'] = Array.isArray(body.results);
tests['Has total count'] = body.total !== undefined;
tests['Has success count'] = body.success !== undefined;
} else {
tests['Bulk update failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Update Books
Updates multiple books in a single request with different fields for each book.
**Method:** POST
**Endpoint:** /api/books/bulk-update
**Authentication:** Required (Bearer token)
**Request Body:**
- `updates` (array): Array of update objects
- `book_id` (string): Book UUID to update
- `updates` (object): Fields to update (can include title, author, genre, tags, contributors, etc.)
**Response:**
- `results` (array): Results for each update attempt
- `total` (number): Total number of books processed
- `success` (number): Number of successfully updated books
- `failed` (number): Number of failed updates
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Example:**
```json
{
"updates": [
{
"book_id": "uuid-1",
"updates": {
"title": "New Title",
"genre": "Fiction",
"tags": ["fiction", "adventure"]
}
},
{
"book_id": "uuid-2",
"updates": {
"author": "Jane Doe",
"contributors": ["Publisher Inc."]
}
}
]
}
```
**Note:** Each book can have different fields updated. Only the specified fields are modified for each book.
}
-3
View File
@@ -2,9 +2,6 @@ auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}
docs {
# Bruno API Tests for Bookhoard
@@ -7,12 +7,9 @@ meta {
post {
url: {{base_url}}/api/collections/{{collection_id}}/books
body: json
auth: bearer
auth: inherit
}
auth:bearer {
token: {{token}}
}
body:json {
{
@@ -1,49 +1,103 @@
{
"meta": {
"name": "Bulk Add Books to Collections",
"type": "http",
"event": [
meta {
name: Bulk Add Books to Collections
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/collections/bulk-add-books
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"operations": [
{
"listen": "test",
"script": {
"exec": [
"// Test bulk add to collections response",
"if (response.status === 200) {",
" tests['Bulk add successful'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has results array'] = Array.isArray(body.results);",
" tests('All operations processed', body.results.length > 0);",
"} else {",
" tests['Bulk add failed'] = false;",
"}"
]
}
"collection_id": "{{collectionId1}}",
"book_ids": [
"{{bookId1}}",
"{{bookId2}}"
]
},
{
"collection_id": "{{collectionId2}}",
"book_ids": [
"{{bookId3}}"
]
}
]
},
"req": {
"url": "{{baseUrl}}/api/collections/bulk-add-books",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{authToken}}"
},
"body": {
"operations": [
{
"collection_id": "{{collectionId1}}",
"book_ids": [
"{{bookId1}}",
"{{bookId2}}"
]
},
{
"collection_id": "{{collectionId2}}",
"book_ids": [
"{{bookId3}}"
]
}
]
}
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Bulk add successful'] = true;
const body = res.getBody();
tests['Has results array'] = Array.isArray(body.results);
tests['All operations processed'] = body.results.length > 0;
} else {
tests['Bulk add failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Add Books to Collections
Adds multiple books to multiple collections in a single request. Each operation specifies a collection and a list of books to add.
**Method:** POST
**Endpoint:** /api/collections/bulk-add-books
**Authentication:** Bearer token
**Request Body:**
- `operations` (array): Array of collection-book operations
- `collection_id` (string): Collection UUID
- `book_ids` (array): Array of book UUIDs to add to the collection
**Response:**
- `results` (array): Results for each operation
- `total` (number): Total number of operations
- `success` (number): Number of successful operations
- `failed` (number): Number of failed operations
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Example:**
```json
{
"operations": [
{
"collection_id": "collection-uuid-1",
"book_ids": ["book-1", "book-2"]
},
{
"collection_id": "collection-uuid-2",
"book_ids": ["book-3"]
}
]
}
```
**Note:** Adding a book that's already in a collection is idempotent (no error).
}
@@ -0,0 +1,55 @@
meta {
name: Bulk Remove Books - All Books
type: http
seq: 5
}
post {
url: {{baseUrl}}/api/collections/{{collection_id}}/books/bulk-remove
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"book_ids": [
"{{bookId1}}",
"{{bookId2}}",
"{{bookId3}}"
]
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['All books removed'] = true;
const body = res.getBody();
tests['Removed equals total'] = body.removed === body.total;
tests['Total is 3'] = body.total === 3;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Remove Books - All Books
Tests removing multiple books from a collection in a single request.
**Expected Result:** 200 OK with removed: 3, total: 3
**Purpose:** Verifies the bulk remove functionality works correctly when removing multiple books that all exist in the collection.
**Use Case:** Common scenario when a user wants to remove several books from a collection at once, such as when reorganizing their library or removing books they've finished reading.
}
@@ -0,0 +1,46 @@
meta {
name: Bulk Remove Books - Empty List
type: http
seq: 2
}
post {
url: {{baseUrl}}/api/collections/{{collection_id}}/books/bulk-remove
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"book_ids": []
}
}
script:post-response {
function onResponse(res) {
tests['Returns 400 for empty list'] = res.getStatus() === 400;
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Remove Books - Empty List Validation
Tests validation behavior when providing an empty book_ids array.
**Expected Result:** 400 Bad Request
**Validation Rule:** book_ids array must contain at least one book UUID.
**Purpose:** Ensures the API properly validates input and rejects empty removal requests.
}
@@ -0,0 +1,59 @@
meta {
name: Bulk Remove Books - Invalid IDs
type: http
seq: 4
}
post {
url: {{baseUrl}}/api/collections/{{collection_id}}/books/bulk-remove
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"book_ids": [
"{{bookId1}}",
"invalid-uuid-format",
"{{bookId2}}"
]
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Partial success accepted'] = true;
const body = res.getBody();
tests['Has removed count'] = body.removed !== undefined;
tests['Has results array'] = Array.isArray(body.results);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Remove Books - Invalid IDs
Tests behavior when the book_ids array contains invalid UUID formats or non-existent books.
**Expected Result:** 200 OK with partial success
**Purpose:** Verifies that:
- Invalid UUID formats don't crash the endpoint
- Non-existent book IDs are handled gracefully
- Valid IDs in the same request are still processed
- Response includes detailed results showing which succeeded/failed
**Note:** The endpoint should process all valid IDs and report failures for invalid ones, allowing clients to handle partial failures appropriately.
}
@@ -0,0 +1,52 @@
meta {
name: Bulk Remove Books - Single Book
type: http
seq: 3
}
post {
url: {{baseUrl}}/api/collections/{{collection_id}}/books/bulk-remove
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"book_ids": [
"{{bookId1}}"
]
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['Single book removed'] = body.removed === 1;
tests['Total is 1'] = body.total === 1;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Remove Books - Single Book
Tests that bulk remove endpoint works correctly with a single book.
**Expected Result:** 200 OK with removed: 1, total: 1
**Purpose:** Verifies the bulk remove endpoint handles single-item arrays correctly, providing flexibility for clients to use the same endpoint for both single and multiple removals.
**Note:** Using bulk remove for a single book is functionally equivalent to the single remove endpoint but allows for consistent error handling and response format.
}
+97 -84
View File
@@ -1,103 +1,116 @@
meta {
name: "Bulk Remove Books from Collection"
name: Bulk Remove Books from Collection
type: http
seq: 1
}
# Setup: Create a test collection first
post {
url: {{baseUrl}}/api/collections
body: {
name: "Bulk Remove Test Collection"
description: "Collection for testing bulk remove"
color: "#FF5733"
icon: "📚"
}
assert {
res.status: 200
}
# Store collection_id from response
# Note: In actual Bruno, you'd use variables
url: {{baseUrl}}/api/collections/{{collection_id}}/books/bulk-remove
body: json
auth: inherit
}
# Add some books to the collection
post {
url: {{baseUrl}}/api/collections/{collection_id}/books
body: {
book_ids: [
"book-id-1",
"book-id-2",
"book-id-3"
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"book_ids": [
"{{bookId1}}",
"{{bookId2}}",
"{{bookId3}}"
]
}
assert {
res.status: 204
}
}
# Test 1: Bulk remove all books
post {
url: {{baseUrl}}/api/collections/{collection_id}/books/bulk-remove
body: {
book_ids: [
"book-id-1",
"book-id-2",
"book-id-3"
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Bulk remove successful'] = true;
const body = res.getBody();
tests['Has removed count'] = body.removed !== undefined;
tests['Has total count'] = body.total !== undefined;
tests['Has results array'] = Array.isArray(body.results);
} else {
tests['Bulk remove failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Remove Books from Collection
Removes multiple books from a collection in a single request.
**Method:** POST
**Endpoint:** /api/collections/{collection_id}/books/bulk-remove
**Authentication:** Bearer token
**Path Parameters:**
- `collection_id` (string): Collection UUID
**Request Body:**
- `book_ids` (array): Array of book UUIDs to remove from the collection
**Response:**
- `removed` (number): Number of books successfully removed
- `total` (number): Total number of books processed
- `results` (array): Results for each removal attempt
- `book_id` (string): Book UUID
- `success` (boolean): Whether the removal succeeded
- `error` (string, optional): Error message if failed
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data (e.g., empty book_ids array)
- 401: Unauthorized
- 403: Forbidden
- 404: Collection not found
- 500: Internal server error
**Example Request:**
```json
{
"book_ids": [
"book-uuid-1",
"book-uuid-2",
"book-uuid-3"
]
}
assert {
res.status: 200
res.body.removed: #number
res.body.total: 3
}
}
```
# Test 2: Bulk remove with some invalid IDs
post {
url: {{baseUrl}}/api/collections/{collection_id}/books/bulk-remove
body: {
book_ids: [
"book-id-4",
"invalid-id",
"book-id-5"
**Example Response:**
```json
{
"removed": 2,
"total": 3,
"results": [
{
"book_id": "book-uuid-1",
"success": true
},
{
"book_id": "book-uuid-2",
"success": true
},
{
"book_id": "book-uuid-3",
"success": false,
"error": "Book not in collection"
}
]
}
assert {
res.status: 200
res.body.removed: #number
}
}
```
# Test 3: Empty list (should fail validation)
post {
url: {{baseUrl}}/api/collections/{collection_id}/books/bulk-remove
body: {
book_ids: []
}
assert {
res.status: 400
}
}
# Test 4: Single book (bulk remove should work for 1 book too)
post {
url: {{baseUrl}}/api/collections/{collection_id}/books/bulk-remove
body: {
book_ids: [
"book-id-6"
]
}
assert {
res.status: 200
res.body.removed: 1
res.body.total: 1
}
}
# Cleanup: Delete test collection
delete {
url: {{baseUrl}}/api/collections/{collection_id}
assert {
res.status: 204
}
**Note:** Removing a book that's not in the collection returns success: false for that book but doesn't fail the entire request. Empty book_ids array returns 400.
}
+1 -4
View File
@@ -7,12 +7,9 @@ meta {
post {
url: {{base_url}}/api/collections
body: json
auth: bearer
auth: inherit
}
auth:bearer {
token: {{token}}
}
body:json {
{
+1 -2
View File
@@ -7,10 +7,9 @@ meta {
post {
url: {{base_url}}/api/devices/{{device_id}}/collections
body: json
auth: bearer
auth: inherit
}
auth:bearer {
token: {{token}}
}
+1 -5
View File
@@ -6,9 +6,5 @@ meta {
delete {
url: {{base_url}}/api/collections/{{collection_id}}
auth: bearer
}
auth:bearer {
token: {{token}}
auth: inherit
}
+1 -5
View File
@@ -6,9 +6,5 @@ meta {
delete {
url: {{base_url}}/api/devices/{{device_id}}/collections/{{mapping_id}}
auth: bearer
}
auth:bearer {
token: {{token}}
auth: inherit
}
+1 -5
View File
@@ -6,9 +6,5 @@ meta {
get {
url: {{base_url}}/api/collections/books/{{book_id}}
auth: bearer
}
auth:bearer {
token: {{token}}
auth: inherit
}
+1 -5
View File
@@ -6,9 +6,5 @@ meta {
get {
url: {{base_url}}/api/collections/{{collection_id}}
auth: bearer
}
auth:bearer {
token: {{token}}
auth: inherit
}
+1 -5
View File
@@ -6,9 +6,5 @@ meta {
get {
url: {{base_url}}/api/collections?include_auto=true&sort_by=name
auth: bearer
}
auth:bearer {
token: {{token}}
auth: inherit
}
+1 -5
View File
@@ -6,9 +6,5 @@ meta {
get {
url: {{base_url}}/api/devices/{{device_id}}/collections
auth: bearer
}
auth:bearer {
token: {{token}}
auth: inherit
}
@@ -6,9 +6,5 @@ meta {
delete {
url: {{base_url}}/api/collections/{{collection_id}}/books/{{book_id}}
auth: bearer
}
auth:bearer {
token: {{token}}
auth: inherit
}
@@ -0,0 +1,61 @@
meta {
name: Test Collection Rules - Author Contains
type: http
seq: 2
}
post {
url: {{baseUrl}}/api/collections/test-rules
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"rules": [
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Author search successful'] = true;
const body = res.getBody();
tests['Has matches array'] = Array.isArray(body.matches);
tests['Found books by Asimov'] = body.matches.length > 0;
} else {
tests['Author search failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Test Collection Rules - Author Contains
Tests the "contains" operator on the author field to find books by a specific author (partial match).
**Example Use Case:** Finding all books by an author whose name contains "Asimov" (e.g., "Isaac Asimov").
**Operator:** `contains` - Matches if the field contains the specified value as a substring (case-insensitive typically).
**Expected Result:** Returns all books where the author field contains "Asimov".
**Purpose:** Demonstrates text-based partial matching for author searches, useful when you don't need the exact author name or want to find books by authors with similar names.
}
@@ -0,0 +1,63 @@
meta {
name: Test Collection Rules - Copyright Year Greater Than
type: http
seq: 3
}
post {
url: {{baseUrl}}/api/collections/test-rules
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"rules": [
{
"field": "copyright_year",
"operator": "greater_than",
"value": "2000"
}
]
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Year comparison successful'] = true;
const body = res.getBody();
tests['Has matches array'] = Array.isArray(body.matches);
tests('Found books after 2000', body.matches.length >= 0);
} else {
tests['Year comparison failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Test Collection Rules - Copyright Year Greater Than
Tests the "greater_than" operator on the copyright_year field to find books published after a specific year.
**Example Use Case:** Creating a "Modern Books" collection with books published after 2000.
**Operator:** `greater_than` - Matches if the field value is greater than the specified value (numeric comparison).
**Field:** `copyright_year` - The year the book was copyrighted/published.
**Expected Result:** Returns all books with copyright_year greater than 2000 (i.e., published in 2001 or later).
**Purpose:** Demonstrates numeric comparison operators for creating date-based collections, useful for organizing books by publication era.
}
@@ -0,0 +1,48 @@
meta {
name: Test Collection Rules - Empty Rules Array
type: http
seq: 5
}
post {
url: {{baseUrl}}/api/collections/test-rules
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"rules": []
}
}
script:post-response {
function onResponse(res) {
tests['Empty rules rejected'] = res.getStatus() === 400;
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Test Collection Rules - Empty Rules Array
Tests validation behavior when providing an empty rules array.
**Expected Result:** 400 Bad Request
**Validation Rule:** rules array must contain at least one rule object.
**Purpose:** Ensures the API properly validates input and rejects empty rule sets, preventing accidental queries that would return all books or cause performance issues.
**Use Case:** Client-side validation should prevent sending empty rules, but the API should also validate to catch malformed requests.
}
@@ -0,0 +1,63 @@
meta {
name: Test Collection Rules - No Matches
type: http
seq: 4
}
post {
url: {{baseUrl}}/api/collections/test-rules
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "NonExistentGenre123456"
}
]
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['No matches returned'] = body.total === 0;
tests['Empty matches array'] = body.matches.length === 0;
tests['Success with zero results'] = true;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Test Collection Rules - No Matches
Tests behavior when collection rules don't match any books in the library.
**Example Use Case:** Validating that a new genre name doesn't exist before creating a collection for it, or testing edge cases.
**Expected Result:** 200 OK with empty matches array and total: 0
**Purpose:** Verifies that the API handles zero-match scenarios gracefully:
- Returns 200 (success) not 404
- Returns empty array, not null
- Returns total: 0 for clarity
- No errors thrown for no results
**Note:** An empty result set is a valid response and doesn't indicate an error. This allows users to test rules confidently before creating collections.
}
+96 -60
View File
@@ -1,83 +1,119 @@
meta {
name: "Test Collection Rules"
name: Test Collection Rules
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/collections/test-rules
body: {
rules: [
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"rules": [
{
field: "genre",
operator: "equals",
value: "Science Fiction"
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
}
]
}
assert {
res.status: 200
res.body.matches: #array
res.body.total: #number
}
}
post {
url: {{baseUrl}}/api/collections/test-rules
body: {
rules: [
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Rules test successful'] = true;
const body = res.getBody();
tests['Has matches array'] = Array.isArray(body.matches);
tests['Has total count'] = body.total !== undefined;
} else {
tests['Rules test failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Test Collection Rules
Tests collection rules against the library to see which books match, without creating a collection. Useful for previewing what books would be included in a collection with specific rules.
**Method:** POST
**Endpoint:** /api/collections/test-rules
**Authentication:** Bearer token
**Request Body:**
- `rules` (array): Array of rule objects to test
- `field` (string): Field to test (genre, author, copyright_year, tags, etc.)
- `operator` (string): Comparison operator
- `equals`: Exact match
- `contains`: Contains substring (for text fields)
- `greater_than`: Greater than (for numeric fields)
- `less_than`: Less than (for numeric fields)
- `not_equals`: Not equal to
- `starts_with`: Starts with
- `ends_with`: Ends with
- `is_empty`: Field is empty or null
- `is_not_empty`: Field is not empty and not null
- `value` (string): Value to compare against (not required for is_empty/is_not_empty)
**Response:**
- `matches` (array): Array of matching books
- `id` (string): Book UUID
- `title` (string): Book title
- `author` (string): Book author
- `genre` (string): Book genre
- Additional book metadata
- `total` (number): Total number of matching books
**Status Codes:**
- 200: Success - returns matching books
- 400: Invalid request (empty rules array, invalid field/operator)
- 401: Unauthorized
- 500: Internal server error
**Example Request:**
```json
{
"rules": [
{
field: "author",
operator: "contains",
value: "Asimov"
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
}
]
}
assert {
res.status: 200
res.body.matches: #array
}
}
```
post {
url: {{baseUrl}}/api/collections/test-rules
body: {
rules: [
**Example Response:**
```json
{
"matches": [
{
field: "copyright_year",
operator: "greater_than",
value: "2000"
"id": "book-uuid-1",
"title": "Foundation",
"author": "Isaac Asimov",
"genre": "Science Fiction"
}
]
],
"total": 1
}
assert {
res.status: 200
}
}
```
post {
url: {{baseUrl}}/api/collections/test-rules
body: {
rules: [
{
field: "genre",
operator: "equals",
value: "NonExistentGenre123456"
}
]
}
assert {
res.status: 200
res.body.total: 0
}
}
post {
url: {{baseUrl}}/api/collections/test-rules
body: {
rules: []
}
assert {
res.status: 400
}
**Note:** This endpoint is useful for validating collection rules before creating a collection, or for dynamically querying books based on criteria.
}
+1 -4
View File
@@ -7,12 +7,9 @@ meta {
put {
url: {{base_url}}/api/collections/{{collection_id}}
body: json
auth: bearer
auth: inherit
}
auth:bearer {
token: {{token}}
}
body:json {
{
+1 -4
View File
@@ -7,12 +7,9 @@ meta {
put {
url: {{base_url}}/api/devices/{{device_id}}/collections/{{mapping_id}}
body: json
auth: bearer
auth: inherit
}
auth:bearer {
token: {{token}}
}
body:json {
{
+81 -37
View File
@@ -1,40 +1,84 @@
{
"meta": {
"name": "Bulk Dismiss Conflicts",
"type": "http",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Test bulk dismiss response",
"if (response.status === 200) {",
" tests['Bulk dismiss successful'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has results array'] = Array.isArray(body.results);",
" tests['Has total count'] = body.total !== undefined;",
" tests['Has success count'] = body.success !== undefined;",
" tests['Has failed count'] = body.failed !== undefined;",
"} else {",
" tests['Bulk dismiss failed'] = false;",
"}"
]
}
}
meta {
name: Bulk Dismiss Conflicts
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/conflicts/bulk-dismiss
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"conflict_ids": [
"{{conflictId1}}",
"{{conflictId2}}"
]
},
"req": {
"url": "{{baseUrl}}/api/conflicts/bulk-dismiss",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{authToken}}"
},
"body": {
"conflict_ids": [
"{{conflictId1}}",
"{{conflictId2}}"
]
}
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Bulk dismiss successful'] = true;
const body = res.getBody();
tests['Has results array'] = Array.isArray(body.results);
tests['Has total count'] = body.total !== undefined;
tests['Has success count'] = body.success !== undefined;
tests['Has failed count'] = body.failed !== undefined;
} else {
tests['Bulk dismiss failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Dismiss Conflicts
Dismisses multiple sync conflicts without resolving them. This removes them from the conflict list while leaving the data unchanged.
**Method:** POST
**Endpoint:** /api/conflicts/bulk-dismiss
**Authentication:** Bearer token
**Request Body:**
- `conflict_ids` (array): Array of conflict UUIDs to dismiss
**Response:**
- `results` (array): Results for each dismissal
- `total` (number): Total number of conflicts processed
- `success` (number): Number of successfully dismissed conflicts
- `failed` (number): Number of failed dismissals
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 404: One or more conflicts not found
- 500: Internal server error
**Example:**
```json
{
"conflict_ids": ["uuid-1", "uuid-2"]
}
```
**Note:** Dismissing a conflict removes it from the conflict list but does not merge or resolve the conflicting data. Use this when you want to ignore a conflict and handle it manually.
}
+91 -41
View File
@@ -1,43 +1,93 @@
{
"meta": {
"name": "Bulk Resolve Conflicts",
"type": "http",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Test bulk resolution response",
"if (response.status === 200) {",
" tests['Bulk resolve successful'] = true;",
" const body = JSON.parse(response.body);",
" tests['Has results array'] = Array.isArray(body.results);",
" tests['Has total count'] = body.total !== undefined;",
" tests['Has success count'] = body.success !== undefined;",
" tests['Has failed count'] = body.failed !== undefined;",
" tests['Total equals sum of success and failed'] = body.total === body.success + body.failed;",
"} else {",
" tests['Bulk resolve failed'] = false;",
"}"
]
}
}
]
},
"req": {
"url": "{{baseUrl}}/api/conflicts/bulk-resolve",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{authToken}}"
},
"body": {
"conflict_ids": [
"{{conflictId1}}",
"{{conflictId2}}",
"{{conflictId3}}"
],
"strategy": "most_recent"
}
meta {
name: Bulk Resolve Conflicts
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/conflicts/bulk-resolve
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"conflict_ids": [
"{{conflictId1}}",
"{{conflictId2}}",
"{{conflictId3}}"
],
"strategy": "most_recent"
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Bulk resolve successful'] = true;
const body = res.getBody();
tests['Has results array'] = Array.isArray(body.results);
tests['Has total count'] = body.total !== undefined;
tests['Has success count'] = body.success !== undefined;
tests['Has failed count'] = body.failed !== undefined;
tests['Total equals sum of success and failed'] = body.total === body.success + body.failed;
} else {
tests['Bulk resolve failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Resolve Conflicts
Resolves multiple sync conflicts in a single request using a specified resolution strategy.
**Method:** POST
**Endpoint:** /api/conflicts/bulk-resolve
**Authentication:** Bearer token
**Request Body:**
- `conflict_ids` (array): Array of conflict UUIDs to resolve
- `strategy` (string): Resolution strategy
- `most_recent`: Use the most recently updated progress
- `highest_progress`: Use the reading progress with the highest percent read
- `server`: Always prefer server-side data
- `device`: Always prefer device-side data
**Response:**
- `results` (array): Results for each conflict resolution
- `total` (number): Total number of conflicts processed
- `success` (number): Number of successfully resolved conflicts
- `failed` (number): Number of failed resolutions
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 404: One or more conflicts not found
- 500: Internal server error
**Example:**
```json
{
"conflict_ids": ["uuid-1", "uuid-2", "uuid-3"],
"strategy": "most_recent"
}
```
**Note:** Conflicts are resolved atomically per conflict. If one resolution fails, others may still succeed.
}
@@ -1,37 +1,74 @@
{
"meta": {
"name": "Bulk Resolve with Highest Progress Strategy",
"type": "http",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Test highest progress strategy",
"if (response.status === 200) {",
" tests['Highest progress strategy successful'] = true;",
" const body = JSON.parse(response.body);",
" tests['At least one conflict resolved'] = body.success > 0;",
"} else {",
" tests['Strategy failed'] = false;",
"}"
]
}
}
]
},
"req": {
"url": "{{baseUrl}}/api/conflicts/bulk-resolve",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{authToken}}"
},
"body": {
"conflict_ids": [
"{{conflictId1}}"
],
"strategy": "highest_progress"
}
meta {
name: Bulk Resolve with Highest Progress Strategy
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/conflicts/bulk-resolve
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"conflict_ids": [
"{{conflictId1}}"
],
"strategy": "highest_progress"
}
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
tests['Highest progress strategy successful'] = true;
const body = res.getBody();
tests['At least one conflict resolved'] = body.success > 0;
} else {
tests['Strategy failed'] = false;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Resolve with Highest Progress Strategy
Resolves multiple sync conflicts using the "highest_progress" strategy, which keeps the reading progress with the highest percentage read.
**Method:** POST
**Endpoint:** /api/conflicts/bulk-resolve
**Authentication:** Bearer token
**Request Body:**
- `conflict_ids` (array): Array of conflict UUIDs to resolve
- `strategy` (string): Must be "highest_progress"
**Response:**
- `results` (array): Results for each conflict resolution
- `total` (number): Total number of conflicts processed
- `success` (number): Number of successfully resolved conflicts
- `failed` (number): Number of failed resolutions
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Note:** The highest progress strategy is ideal when you want to preserve the most reading progress across devices. Use this when you've been reading on multiple devices and want to keep the furthest position.
}
+35 -18
View File
@@ -7,27 +7,44 @@ meta {
delete {
url: {{baseUrl}}/api/conflicts/{{conflict_id}}
body: none
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
}
docs: {
Deletes a specific conflict record.
Path Parameters:
- conflict_id: UUID of the conflict to delete
Use this when:
- A conflict was created in error
- You want to dismiss a conflict without resolving it
- The conflict is no longer relevant
Response: 204 No Content on success
Note: This permanently removes the conflict record.
Consider resolving the conflict instead if you want to
maintain an audit trail of what happened.
settings {
encodeUrl: true
timeout: 0
}
docs {
## Delete Conflict
Permanently deletes a specific conflict record from the system.
**Method:** DELETE
**Endpoint:** /api/conflicts/{conflict_id}
**Authentication:** Bearer token
**Path Parameters:**
- `conflict_id` (string): Conflict UUID to delete
**Response:** 204 No Content on success
**Status Codes:**
- 204: Success - conflict deleted
- 401: Unauthorized
- 404: Conflict not found
- 500: Internal server error
**Use Cases:**
- Conflict was created in error
- Dismissing a conflict without resolving it
- Conflict is no longer relevant (e.g., book deleted)
**Note:** This permanently removes the conflict record with no undo option. Consider resolving the conflict instead if you want to maintain an audit trail of what happened.
}
+36 -17
View File
@@ -7,29 +7,48 @@ meta {
post {
url: {{baseUrl}}/api/conflicts/dismiss-all
body: none
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
}
docs: {
Deletes all resolved conflicts for the authenticated user.
Use this to:
- Clean up your conflicts list after reviewing resolutions
- Remove old resolved conflicts that are no longer needed
- Maintain a clean conflict history
Response includes:
- deleted: Number of conflict records that were deleted
Example response:
settings {
encodeUrl: true
timeout: 0
}
docs {
## Dismiss All Resolved Conflicts
Deletes all resolved conflicts for the authenticated user, cleaning up the conflict list.
**Method:** POST
**Endpoint:** /api/conflicts/dismiss-all
**Authentication:** Bearer token
**Response:**
- `deleted` (number): Number of conflict records that were deleted
**Status Codes:**
- 200: Success - conflicts deleted
- 401: Unauthorized
- 500: Internal server error
**Example Response:**
```json
{
"deleted": 5
}
Note: Only resolves conflicts with status "user_resolved"
are deleted. Unresolved conflicts are preserved.
```
**Use Cases:**
- Clean up conflicts list after reviewing resolutions
- Remove old resolved conflicts no longer needed
- Maintain a clean conflict history
**Note:** Only conflicts with status "user_resolved" or "auto_resolved" are deleted. Unresolved conflicts are preserved.
}
+74 -39
View File
@@ -7,52 +7,87 @@ meta {
get {
url: {{baseUrl}}/api/conflicts/{{conflict_id}}
body: none
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
}
docs: {
Retrieves detailed information about a specific conflict.
Path Parameters:
- conflict_id: UUID of the conflict
Response includes:
- id: Conflict UUID
- media_item_id: Associated book UUID
- media_item_title: Book title
- conflict_type: Type of conflict
- conflict_data: Side-by-side comparison with sources:
* source: Device/source identifier (koreader, kobo, web, etc.)
* timestamp: When this progress was recorded
* data: The conflicting data (percentage, epubcfi, chapter, etc.)
- resolution_status: Current status
- resolution_data: If resolved, includes resolution details
- resolved_by: User ID who resolved it (if applicable)
- resolved_at: When it was resolved (if applicable)
- created_at: When conflict was detected
Example conflict_data:
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Conflict Details
Retrieves detailed information about a specific conflict, including side-by-side comparison of conflicting data from all sources.
**Method:** GET
**Endpoint:** /api/conflicts/{conflict_id}
**Authentication:** Bearer token
**Path Parameters:**
- `conflict_id` (string): Conflict UUID
**Response:**
- `id` (string): Conflict UUID
- `media_item_id` (string): Associated book UUID
- `media_item_title` (string): Book title
- `conflict_type` (string): Type of conflict (progress, note, highlight)
- `conflict_data` (object): Side-by-side comparison from each source
- Each source includes:
- `source` (string): Device/source identifier (koreader, kobo, web, etc.)
- `timestamp` (string): When this data was recorded
- `data` (object): The conflicting data
- `percentage` (number): Reading progress
- `epubcfi` (string): EPUB location
- `chapter` (number): Chapter number
- `resolution_status` (string): Current status (unresolved, user_resolved, auto_resolved)
- `resolution_data` (object, optional): If resolved, includes resolution details
- `resolved_by` (string, optional): User ID who resolved it
- `resolved_at` (string, optional): When it was resolved
- `created_at` (string): When conflict was detected
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Conflict not found
- 500: Internal server error
**Example Response:**
```json
{
"koreader": {
"source": "koreader",
"timestamp": "2026-01-30T20:10:00Z",
"data": {
"percentage": 0.45,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3
"id": "conflict-uuid",
"media_item_id": "book-uuid",
"media_item_title": "Foundation",
"conflict_type": "progress",
"conflict_data": {
"koreader": {
"source": "koreader",
"timestamp": "2026-01-30T20:10:00Z",
"data": {
"percentage": 0.45,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3
}
},
"kobo": {
"source": "kobo",
"timestamp": "2026-01-30T20:05:00Z",
"data": {
"percentage": 0.42,
"location": "unknown"
}
}
},
"kobo": {
"source": "kobo",
"timestamp": "2026-01-30T20:05:00Z",
"data": {
"percentage": 0.42,
"location": "unknown"
}
}
"resolution_status": "unresolved",
"created_at": "2026-01-30T20:10:00Z"
}
```
**Note:** Use this to get full details before resolving, showing exactly what data differs between sources.
}
+79 -21
View File
@@ -7,30 +7,88 @@ meta {
get {
url: {{baseUrl}}/api/conflicts?status=unresolved
body: none
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
}
docs: {
Lists all sync conflicts for the authenticated user.
Query Parameters:
- status: Filter by resolution status (unresolved, user_resolved, auto_resolved, all)
Response includes:
- conflicts: Array of conflict details
- total: Total number of conflicts
- unresolved: Number of unresolved conflicts
Each conflict includes:
- id: Conflict UUID
- media_item_id: Associated book UUID
- media_item_title: Book title
- conflict_type: Type of conflict (progress, note, highlight)
- conflict_data: Side-by-side comparison of conflicting data
- resolution_status: Current status
- created_at: When conflict was detected
settings {
encodeUrl: true
timeout: 0
}
docs {
## List Conflicts
Lists all sync conflicts for the authenticated user with optional filtering.
**Method:** GET
**Endpoint:** /api/conflicts
**Authentication:** Bearer token
**Query Parameters:**
- `status` (string, optional): Filter by resolution status
- `unresolved`: Only unresolved conflicts (default)
- `user_resolved`: Conflicts resolved by user
- `auto_resolved`: Automatically resolved conflicts
- `all`: All conflicts regardless of status
- `type` (string, optional): Filter by conflict type
- `progress`: Reading progress conflicts
- `note`: Bookmark/note conflicts
- `highlight`: Highlight conflicts
**Response:**
- `conflicts` (array): Array of conflict objects
- `total` (number): Total number of conflicts matching filters
- `unresolved` (number): Number of unresolved conflicts
**Each Conflict Object:**
- `id` (string): Conflict UUID
- `media_item_id` (string): Associated book UUID
- `media_item_title` (string): Book title
- `conflict_type` (string): Type of conflict (progress, note, highlight)
- `conflict_data` (object): Side-by-side comparison of conflicting data
- `koreader`: Data from KOReader device
- `kobo`: Data from Kobo device
- `web`: Data from web interface
- `resolution_status` (string): Current status (unresolved, user_resolved, auto_resolved)
- `created_at` (string): ISO 8601 timestamp when conflict was detected
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
**Example Request:**
```
GET /api/conflicts?status=unresolved&type=progress
```
**Example Response:**
```json
{
"conflicts": [
{
"id": "conflict-uuid",
"media_item_id": "book-uuid",
"media_item_title": "Foundation",
"conflict_type": "progress",
"conflict_data": {
"koreader": { "percentage": 0.65, "epubcfi": "..." },
"kobo": { "percentage": 0.43, "epubcfi": "..." }
},
"resolution_status": "unresolved",
"created_at": "2026-01-31T12:00:00Z"
}
],
"total": 1,
"unresolved": 1
}
```
**Note:** Conflicts occur when multiple devices update the same book data without syncing first.
}
+64 -27
View File
@@ -7,10 +7,10 @@ meta {
post {
url: {{baseUrl}}/api/conflicts/{{conflict_id}}/resolve
body: json
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
Content-Type: application/json
}
@@ -24,25 +24,63 @@ body:json {
}
}
docs: {
Resolves a sync conflict by choosing which source to use.
Path Parameters:
- conflict_id: UUID of the conflict to resolve
Request Body:
- winner: Source to choose (koreader, kobo, web, manual)
- manual_data: Required if winner is "manual" - contains the merged data
- apply_to_all_future_conflicts: Whether to auto-resolve future conflicts from this source
- reason: Optional explanation for the resolution
Example request body for choosing koreader:
settings {
encodeUrl: true
timeout: 0
}
docs {
## Resolve Conflict
Resolves a sync conflict by choosing which source to use for the conflicting data.
**Method:** POST
**Endpoint:** /api/conflicts/{conflict_id}/resolve
**Authentication:** Bearer token
**Path Parameters:**
- `conflict_id` (string): Conflict UUID
**Request Body:**
- `winner` (string): Source to choose
- `koreader`: Use KOReader device data
- `kobo`: Use Kobo device data
- `web`: Use web interface data
- `manual`: Use custom merged data (requires manual_data)
- `manual_data` (object, optional): Required if winner is "manual"
- `percentage` (number): Reading progress percentage (0-1)
- `epubcfi` (string): EPUB Canonical Fragment Identifier
- `chapter` (number, optional): Chapter number
- `page` (number, optional): Page number
- `apply_to_all_future_conflicts` (boolean): Auto-resolve future conflicts from this source
- `reason` (string, optional): Explanation for the resolution choice
**Response:**
- `conflict_resolved` (boolean): True if successful
- `applied_to` (string): What was updated (progress, annotations, etc.)
- `devices_synced` (array): List of device IDs that were notified
**Status Codes:**
- 200: Success - conflict resolved
- 400: Invalid request data
- 401: Unauthorized
- 404: Conflict not found
- 500: Internal server error
**Example - Choose KOReader:**
```json
{
"winner": "koreader",
"manual_data": null,
"apply_to_all_future_conflicts": false,
"reason": "More recent progress"
}
Example request body for manual resolution:
```
**Example - Manual Override:**
```json
{
"winner": "manual",
"manual_data": {
@@ -50,17 +88,16 @@ docs: {
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 3
},
"apply_to_all_future_conflicts": false,
"reason": "Custom merged position"
}
Response includes:
- conflict_resolved: true if successful
- applied_to: What was updated (progress, annotations)
- devices_synced: List of device IDs that were notified
After resolution:
- The winning data is applied to the reading progress
- All connected devices are notified via WebSocket
```
**After Resolution:**
- Winning data is applied to reading progress
- All connected devices notified via WebSocket
- Conflict status changes to "user_resolved"
- Resolution data is stored for audit trail
- Resolution stored for audit trail
**Note:** Manual override allows precise control when automatic resolution doesn't capture the correct state.
}
-127
View File
@@ -1,127 +0,0 @@
meta {
name: "Bookhoard Conflicts API"
type: "collection"
environment: {
development: {
base_url: "http://localhost:8765/api"
},
production: {
base_url: "https://your-domain.com/api"
}
}
}
# List Conflicts
@name("List Unresolved Conflicts")
GET {{environment.base_url}}/conflicts?status=unresolved
Authorization: Bearer {{jwt_token}}
@name("List All Conflicts")
GET {{environment.base_url}}/conflicts?status=all
Authorization: Bearer {{jwt_token}}
@name("List Resolved Conflicts")
GET {{environment.base_url}}/conflicts?status=resolved
Authorization: Bearer {{jwt_token}}
@name("List Progress Conflicts")
GET {{environment.base_url}}/conflicts?status=unresolved&type=progress
Authorization: Bearer {{jwt_token}}
@name("List Note Conflicts")
GET {{environment.base_url}}/conflicts?status=unresolved&type=note
Authorization: Bearer {{jwt_token}}
@name("List Highlight Conflicts")
GET {{environment.base_url}}/conflicts?status=unresolved&type=highlight
Authorization: Bearer {{jwt_token}}
# Get Conflict Details
@name("Get Conflict Details")
GET {{environment.base_url}}/conflicts/{{conflict_id}}
Authorization: Bearer {{jwt_token}}
# Resolve Conflict
@name("Resolve - Keep KOReader Progress")
POST {{environment.base_url}}/conflicts/{{conflict_id}}/resolve
Authorization: Bearer {{jwt_token}}
Content-Type: application/json
{
"winner": "koreader",
"apply_to_all_future_conflicts": false,
"reason": "KOReader was most recently updated"
}
@name("Resolve - Keep Kobo Progress")
POST {{environment.base_url}}/conflicts/{{conflict_id}}/resolve
Authorization: Bearer {{jwt_token}}
Content-Type: application/json
{
"winner": "kobo",
"apply_to_all_future_conflicts": false,
"reason": "Kobo has more progress"
}
@name("Resolve - Keep Web Progress")
POST {{environment.base_url}}/conflicts/{{conflict_id}}/resolve
Authorization: Bearer {{jwt_token}}
Content-Type: application/json
{
"winner": "web",
"apply_to_all_future_conflicts": false,
"reason": "Web interface has most recent data"
}
@name("Resolve - Manual Override")
POST {{environment.base_url}}/conflicts/{{conflict_id}}/resolve
Authorization: Bearer {{jwt_token}}
Content-Type: application/json
{
"winner": "manual",
"manual_data": {
"percentage": 0.43,
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 3
},
"apply_to_all_future_conflicts": false,
"reason": "User specified custom progress"
}
@name("Resolve - Manual Override with Page")
POST {{environment.base_url}}/conflicts/{{conflict_id}}/resolve
Authorization: Bearer {{jwt_token}}
Content-Type: application/json
{
"winner": "manual",
"manual_data": {
"percentage": 0.43,
"page": 89,
"epubcfi": "epubcfi(/6/4/2:20)"
},
"apply_to_all_future_conflicts": false,
"reason": "User specified page number"
}
@name("Resolve - Apply to All Future Conflicts")
POST {{environment.base_url}}/conflicts/{{conflict_id}}/resolve
Authorization: Bearer {{jwt_token}}
Content-Type: application/json
{
"winner": "koreader",
"apply_to_all_future_conflicts": true,
"reason": "Always prefer KOReader for future conflicts"
}
# Delete Conflict
@name("Delete Conflict")
DELETE {{environment.base_url}}/conflicts/{{conflict_id}}
Authorization: Bearer {{jwt_token}}
@name("Dismiss Conflict")
DELETE {{environment.base_url}}/conflicts/{{conflict_id}}
Authorization: Bearer {{jwt_token}}
# Batch Operations
@name("Dismiss All Resolved Conflicts")
DELETE {{environment.base_url}}/conflicts/dismiss-resolved
Authorization: Bearer {{jwt_token}}
+55 -16
View File
@@ -4,23 +4,62 @@ meta {
seq: 1
}
POST {{baseURL}}/api/devices/{{deviceID}}/shelves
Authorization: Bearer {{userToken}}
Content-Type: application/json
{
"media_item_ids": [
"{{bookUUID1}}",
"{{bookUUID2}}"
],
"shelf_name": "Reading List",
"shelf_position": 0
post {
url: {{baseURL}}/api/devices/{{deviceID}}/shelves
body: json
auth: inherit
}
{
"meta": {
"name": "Add Books to Kobo Shelf",
"description": "Add one or more books to a Kobo device shelf",
"documentation": "Manages which books should be synced to a specific Kobo device. Supports multiple shelves for organization."
headers {
Authorization: Bearer {{userToken}}
Content-Type: application/json
}
body:json {
{
"media_item_ids": [
"{{bookUUID1}}",
"{{bookUUID2}}"
],
"shelf_name": "Reading List",
"shelf_position": 0
}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Add Books to Kobo Shelf
Add one or more books to a Kobo device shelf. Manages which books should be synced to a specific Kobo device. Supports multiple shelves for organization.
**Method:** POST
**Endpoint:** /api/devices/{deviceID}/shelves
**Authentication:** Bearer token
**Path Parameters:**
- `deviceID` (string): Device UUID
**Request Body:**
- `media_item_ids` (array): Array of book UUIDs to add
- `shelf_name` (string): Name of the shelf (e.g., "Reading List", "Favorites")
- `shelf_position` (number, optional): Position on the shelf (default: 0)
**Response:**
- `message` (string): Success message
- `added_count` (number): Number of books added
**Status Codes:**
- 200: Success - books added to shelf
- 400: Invalid request data
- 401: Unauthorized
- 404: Device or one or more books not found
- 500: Internal server error
**Note:** Adding a book that's already on the shelf updates its position if a new position is specified. Supports multiple shelves for organizing content on the Kobo device.
}
+9 -13
View File
@@ -7,36 +7,32 @@ meta {
get {
url: {{base_url}}/api/devices/approve/{{registration_id}}
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## Approve Device Registration
Approves a pending device registration request.
**Method:** GET
**Endpoint:** /api/devices/approve/:registration_id
**Authentication:** Bearer token
**Path Parameters:**
- `registration_id` (string): Registration request UUID
**Response:**
- Success message with approved device details
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Registration not found
- 400: Invalid registration status
**Example Response:**
```json
{
+43 -9
View File
@@ -4,13 +4,47 @@ meta {
seq: 1
}
DELETE {{baseURL}}/api/devices/{{deviceID}}/shelves/clear?shelf={{shelfName}}
Authorization: Bearer {{userToken}}
{
"meta": {
"name": "Clear Kobo Shelf",
"description": "Clear all books from a Kobo device shelf (or all shelves if no shelf name specified)",
"documentation": "Removes all books from the specified shelf. If no shelf name is provided, clears all shelves for the device."
}
delete {
url: {{baseURL}}/api/devices/{{deviceID}}/shelves/clear?shelf={{shelfName}}
body: none
auth: inherit
}
headers {
Authorization: Bearer {{userToken}}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Clear Kobo Shelf
Clear all books from a Kobo device shelf, or all shelves if no shelf name is specified.
**Method:** DELETE
**Endpoint:** /api/devices/{deviceID}//shelves/clear?shelf={shelfName}
**Authentication:** Bearer token
**Path Parameters:**
- `deviceID` (string): Device UUID
**Query Parameters:**
- `shelf` (string, optional): Shelf name to clear. If omitted, clears all shelves.
**Response:**
- `message` (string): Success message
- `cleared_count` (number): Number of books removed from shelf
**Status Codes:**
- 200: Success - shelf cleared
- 401: Unauthorized
- 404: Device not found
- 500: Internal server error
**Note:** Removes all books from the specified shelf. If no shelf name is provided, clears all shelves for the device. This operation cannot be undone.
}
+32 -2
View File
@@ -7,9 +7,39 @@ meta {
delete {
url: {{baseUrl}}/api/devices/{{deviceId}}
body: none
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Delete Device
Deletes a device and unregisters it from the user's account.
**Method:** DELETE
**Endpoint:** /api/devices/{deviceId}
**Authentication:** Bearer token
**Path Parameters:**
- `deviceId` (string): Device UUID
**Response:** 204 No Content on success
**Status Codes:**
- 204: Success - device deleted
- 401: Unauthorized
- 404: Device not found
- 500: Internal server error
**Note:** This action cannot be undone. All device data and sync history will be removed.
}
+37 -2
View File
@@ -7,9 +7,44 @@ meta {
get {
url: {{baseUrl}}/api/devices/{{deviceId}}
body: none
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Device
Retrieves detailed information about a specific device.
**Method:** GET
**Endpoint:** /api/devices/{deviceId}
**Authentication:** Bearer token
**Path Parameters:**
- `deviceId` (string): Device UUID
**Response:**
- `id` (string): Device UUID
- `name` (string): Device name
- `device_type` (string): Type (kobo, koreader, web)
- `last_sync` (string): Last sync timestamp
- `is_active` (boolean): Whether device is active
- `created_at` (string): Registration timestamp
- `sync_settings` (object): Device-specific sync settings
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
- 500: Internal server error
}
+50 -10
View File
@@ -1,16 +1,56 @@
meta {
name: Get Kobo Shelf
name: Get Kobo Shelf Books
type: http
seq: 1
}
GET {{baseURL}}/api/devices/{{deviceID}}/shelves?shelf={{shelfName}}
Authorization: Bearer {{userToken}}
{
"meta": {
"name": "Get Kobo Shelf Books",
"description": "Get all books on a Kobo device shelf, optionally filter by shelf name",
"documentation": "Returns a list of books with their shelf positions and Kobo-specific metadata (entitlement ID, revision number)."
}
get {
url: {{baseURL}}/api/devices/{{deviceID}}/shelves?shelf={{shelfName}}
body: none
auth: inherit
}
headers {
Authorization: Bearer {{userToken}}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Kobo Shelf Books
Get all books on a Kobo device shelf, optionally filter by shelf name.
**Method:** GET
**Endpoint:** /api/devices/{deviceID}/shelves?shelf={shelfName}
**Authentication:** Bearer token
**Path Parameters:**
- `deviceID` (string): Device UUID
**Query Parameters:**
- `shelf` (string, optional): Shelf name to filter by. If omitted, returns all shelves.
**Response:**
- Array of books with:
- `id` (string): Book UUID
- `title` (string): Book title
- `author` (string): Book author
- `shelf_name` (string): Name of the shelf
- `shelf_position` (number): Position on the shelf
- `entitlement_id` (string): Kobo entitlement ID
- `revision_id` (string): Kobo revision number
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
- 500: Internal server error
**Note:** Returns Kobo-specific metadata (entitlement ID, revision number) required for proper Kobo sync operations.
}
+33 -2
View File
@@ -7,9 +7,40 @@ meta {
get {
url: {{baseUrl}}/api/devices
body: none
auth: bearer
auth: inherit
}
headers: {
headers {
Authorization: Bearer {{token}}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## List Devices
Lists all devices registered to the authenticated user's account.
**Method:** GET
**Endpoint:** /api/devices
**Authentication:** Bearer token
**Response:**
- `devices` (array): Array of device objects
- `id` (string): Device UUID
- `name` (string): Device name
- `device_type` (string): Type (kobo, koreader, web)
- `last_sync` (string): Last sync timestamp
- `is_active` (boolean): Whether device is active
- `created_at` (string): Registration timestamp
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
+8 -12
View File
@@ -7,31 +7,27 @@ meta {
get {
url: {{base_url}}/api/devices/pending
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## List Pending Device Registrations
Retrieves all pending device registration requests awaiting approval.
**Method:** GET
**Endpoint:** /api/devices/pending
**Authentication:** Bearer token
**Response:**
- Array of pending device registrations
**Status Codes:**
- 200: Success
- 401: Unauthorized
**Example Response:**
```json
[
+9 -13
View File
@@ -7,36 +7,32 @@ meta {
post {
url: {{base_url}}/api/devices/reject/{{registration_id}}
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## Reject Device Registration
Rejects a pending device registration request.
**Method:** POST
**Endpoint:** /api/devices/reject/:registration_id
**Authentication:** Bearer token
**Path Parameters:**
- `registration_id` (string): Registration request UUID
**Response:**
- Success message confirming rejection
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Registration not found
- 400: Invalid registration status
**Example Response:**
```json
{
+42 -9
View File
@@ -4,13 +4,46 @@ meta {
seq: 1
}
DELETE {{baseURL}}/api/devices/{{deviceID}}/shelves?media_item_id={{bookUUID}}
Authorization: Bearer {{userToken}}
{
"meta": {
"name": "Remove Book from Kobo Shelf",
"description": "Remove a specific book from a Kobo device shelf",
"documentation": "Removes the book from the device's shelf, preventing it from syncing to that device."
}
delete {
url: {{baseURL}}/api/devices/{{deviceID}}/shelves?media_item_id={{bookUUID}}
body: none
auth: inherit
}
headers {
Authorization: Bearer {{userToken}}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Remove Book from Kobo Shelf
Remove a specific book from a Kobo device shelf, preventing it from syncing to that device.
**Method:** DELETE
**Endpoint:** /api/devices/{deviceID}/shelves?media_item_id={bookUUID}
**Authentication:** Bearer token
**Path Parameters:**
- `deviceID` (string): Device UUID
**Query Parameters:**
- `media_item_id` (string): Book UUID to remove from shelf
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success - book removed from shelf
- 401: Unauthorized
- 404: Device or book not found
- 500: Internal server error
**Note:** Removing a book from the device shelf prevents it from syncing to that device in future sync operations.
}
+1 -5
View File
@@ -7,11 +7,7 @@ meta {
put {
url: {{baseUrl}}/api/devices/{{deviceId}}
body: json
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
body:json {
-76
View File
@@ -1,76 +0,0 @@
meta {
name: Bookhoard Kobo Sync
type: collection
environment: Bookhoard
}
### Kobo Initialization Endpoint
GET {{baseURL}}/api/sync/kobo/v1/initialization
Authorization: Bearer {{koboToken}}
x-kobo-device: {"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}
{
"meta": {
"name": "Kobo Initialization",
"description": "Initialize Kobo sync by returning device resources and account page"
}
}
### Kobo Library Sync Endpoint
POST {{baseURL}}/api/sync/kobo/markup
Authorization: Bearer {{koboToken}}
x-kobo-device: {"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}
{
"ReadingSync": [
{
"ContentId": "book-uuid-here",
"PercentRead": 45.6,
"EntitlementId": "entitlement-id",
"RemainingTimeMinutes": 120,
"LastModified": "2026-01-30T20:00:00Z"
}
],
"BookmarkSync": [
{
"BookmarkId": "bookmark-id-1",
"ContentId": "book-uuid-here",
"BookmarkText": "highlighted text here",
"BookmarkType": "annotation",
"BookmarkTitle": "Chapter 3"
}
]
}
### Kobo Bookmark Sync Endpoint
POST {{baseURL}}/api/sync/kobo/bookmark
Authorization: Bearer {{koboToken}}
x-kobo-device: {"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}
{
"BookmarkSync": [
{
"BookmarkId": "bookmark-id-2",
"ContentId": "book-uuid-here",
"BookmarkText": "This is my note abouts book",
"BookmarkType": "bookmark",
"BookmarkTitle": "Important note"
}
]
}
### Kobo Analytics Tests Endpoint
POST {{baseURL}}/api/sync/kobo/v1/analytics/gettests
Authorization: Bearer {{koboToken}}
x-kobo-device: {"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}
{
"ContentId": "book-uuid-here",
"ReadingEvent": "Reading",
"RemainingTimeMin": 180,
"PercentRead": 67.8
}
+84 -38
View File
@@ -1,44 +1,90 @@
meta {
name: Server Sync to Kobo
name: Sync from Bookhoard to Kobo
type: http
seq: 1
}
POST {{baseURL}}/api/sync/kobo/sync-from-server
Authorization: Bearer {{koboToken}}
x-kobo-device: {"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}
Content-Type: application/json
[
{
"ContentId": "{{bookUUID}}",
"PercentRead": 65.4,
"LastModified": "2026-01-31T12:00:00Z",
"Bookmarks": [
{
"BookmarkId": "bookmark-123",
"ContentId": "{{bookUUID}}",
"BookmarkText": "This is an important note",
"BookmarkType": "bookmark",
"BookmarkTitle": "Chapter 5 Note"
}
],
"Highlights": [
{
"BookmarkId": "highlight-456",
"ContentId": "{{bookUUID}}",
"BookmarkText": "highlighted passage text",
"BookmarkType": "annotation",
"BookmarkTitle": "Chapter 3 Highlight"
}
]
}
]
{
"meta": {
"name": "Sync from Bookhoard to Kobo",
"description": "Server-initiated sync pushing progress, bookmarks, and highlights from Bookhoard to Kobo device",
"documentation": "Two-way sync endpoint. Allows Bookhoard server to push updates to Kobo device, including reading progress, bookmarks, and highlights."
}
post {
url: {{baseURL}}/api/sync/kobo/sync-from-server
body: json
auth: inherit
}
headers {
Authorization: Bearer {{koboToken}}
Content-Type: application/json
x-kobo-device: {"DeviceId":"kobo-clara-test","Model":"Kobo Clara","SerialNumber":"N123456789"}
}
body:json {
[
{
"ContentId": "{{bookUUID}}",
"PercentRead": 65.4,
"LastModified": "2026-01-31T12:00:00Z",
"Bookmarks": [
{
"BookmarkId": "bookmark-123",
"ContentId": "{{bookUUID}}",
"BookmarkText": "This is an important note",
"BookmarkType": "bookmark",
"BookmarkTitle": "Chapter 5 Note"
}
],
"Highlights": [
{
"BookmarkId": "highlight-456",
"ContentId": "{{bookUUID}}",
"BookmarkText": "highlighted passage text",
"BookmarkType": "annotation",
"BookmarkTitle": "Chapter 3 Highlight"
}
]
}
]
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Sync from Bookhoard to Kobo
Server-initiated sync pushing progress, bookmarks, and highlights from Bookhoard to Kobo device. Two-way sync endpoint.
**Method:** POST
**Endpoint:** /api/sync/kobo/sync-from-server
**Authentication:** Bearer token with Kobo device identification
**Headers:**
- `x-kobo-device` (string): JSON string containing Kobo device info
- `DeviceId`: Kobo device ID
- `Model`: Kobo device model
- `SerialNumber`: Kobo device serial number
**Request Body:** Array of sync data objects
- `ContentId` (string): Book UUID
- `PercentRead` (number): Reading progress percentage (0-100)
- `LastModified` (string): ISO 8601 timestamp
- `Bookmarks` (array, optional): Array of bookmark objects
- `BookmarkId`: Unique bookmark ID
- `ContentId`: Book UUID
- `BookmarkText`: Bookmark text/note
- `BookmarkType`: Type (bookmark, annotation, etc.)
- `BookmarkTitle`: Bookmark title
- `Highlights` (array, optional): Array of highlight objects (same structure as bookmarks)
**Response:**
- Sync result confirmation
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
**Note:** Allows Bookhoard server to push updates to Kobo device, including reading progress, bookmarks, and highlights.
}
+55 -10
View File
@@ -1,12 +1,22 @@
meta {
name: "Kobo Bookmark Sync - Enhanced with ContentId Mapping"
name: Kobo Bookmark Sync
type: http
seq: 3
}
post {
url: {{base_url}}/api/v1/kobo/bookmark
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{device_token}}
Content-Type: application/json
}
body:json {
{
"BookmarkSync": [
{
"BookmarkId": "bookmark_2",
@@ -16,15 +26,50 @@ post {
"DateCreated": "2026-01-31T12:00:00Z"
}
]
})
auth: {
type: bearer
bearer: {{device_token}}
}
}
assert {
response.status == 200
response.body.Status == "Success"
response.body.BookmarksSynced >= 0
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Status is Success', body.Status === "Success");
tests('BookmarksSynced >= 0', body.BookmarksSynced >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Kobo Bookmark Sync
Synchronizes bookmarks from a Kobo device to the Bookhoard server.
**Method:** POST
**Endpoint:** /api/v1/kobo/bookmark
**Authentication:** Bearer token (device token)
**Request Body:**
- `BookmarkSync` (array): Array of bookmark objects
- `BookmarkId` (string): Unique bookmark ID
- `ContentId` (string): Book/content ID
- `BookmarkText` (string): Bookmark text or note
- `BookmarkType` (string): Type (bookmark, highlight, note)
- `DateCreated` (string): ISO 8601 timestamp
**Response:**
- `Status` (string): Sync status (Success, Partial)
- `BookmarksSynced` (number): Number of bookmarks synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
+44 -10
View File
@@ -1,5 +1,5 @@
meta {
name: "Kobo Initialization - Enhanced with ContentId Mapping"
name: Kobo Initialization
type: http
seq: 1
}
@@ -7,15 +7,49 @@ meta {
get {
url: {{base_url}}/api/v1/kobo/initialization
body: none
auth: {
type: bearer
bearer: {{device_token}}
}
auth: inherit
}
assert {
response.status == 200
response.body.ContentId exists()
response.body.Categories exists()
response.body.BookhoardUUID exists()
headers {
Authorization: Bearer {{device_token}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Has ContentId', body.ContentId !== undefined);
tests('Has Categories', body.Categories !== undefined);
tests('Has BookhoardUUID', body.BookhoardUUID !== undefined);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Kobo Initialization
Initializes Kobo device sync, returning device resources and account information.
**Method:** GET
**Endpoint:** /api/v1/kobo/initialization
**Authentication:** Bearer token (device token)
**Response:**
- `ContentId` (string): Device content ID
- `Categories` (array): Available categories/collections
- `BookhoardUUID` (string): Bookhoard instance UUID
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
+65 -11
View File
@@ -1,12 +1,22 @@
meta {
name: "Kobo Markup Sync - Enhanced with ContentId Mapping"
name: Kobo Markup Sync
type: http
seq: 2
}
post {
url: {{base_url}}/api/v1/kobo/markup
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{device_token}}
Content-Type: application/json
}
body:json {
{
"ReadingSync": [
{
"ContentId": "kobo_abc123def456",
@@ -26,16 +36,60 @@ post {
}
],
"Metadata": true
})
auth: {
type: bearer
bearer: {{device_token}}
}
}
assert {
response.status == 200
response.body.Status == "Success" || response.body.Status == "Partial"
response.body.MarkupsSynced >= 0
response.body.BookmarksSynced >= 0
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
const validStatus = body.Status === "Success" || body.Status === "Partial";
tests('Status is Success or Partial', validStatus);
tests('MarkupsSynced >= 0', body.MarkupsSynced >= 0);
tests('BookmarksSynced >= 0', body.BookmarksSynced >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Kobo Markup Sync
Synchronizes reading progress and markup (highlights, bookmarks) from a Kobo device.
**Method:** POST
**Endpoint:** /api/v1/kobo/markup
**Authentication:** Bearer token (device token)
**Request Body:**
- `ReadingSync` (array, optional): Reading progress data
- `ContentId` (string): Book/content ID
- `PercentRead` (number): Percentage read (0-100)
- `RemainingTimeMin` (number): Remaining time in minutes
- `ReadingEvent` (string): Event type (BookRead, etc.)
- `LastModified` (string): ISO 8601 timestamp
- `BookmarkSync` (array, optional): Bookmark/highlight data
- `BookmarkId` (string): Unique bookmark ID
- `ContentId` (string): Book/content ID
- `BookmarkText` (string): Highlighted/bookmarked text
- `BookmarkType` (string): Type (annotation, bookmark)
- `DateCreated` (string): ISO 8601 timestamp
- `Metadata` (boolean): Whether to include metadata
**Response:**
- `Status` (string): Sync status (Success, Partial)
- `MarkupsSynced` (number): Number of markups synced
- `BookmarksSynced` (number): Number of bookmarks synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
+50 -9
View File
@@ -6,15 +6,56 @@ meta {
get {
url: {{baseUrl}}/api/sync/koreader/metadata/{{book_uuid}}
headers: {
Authorization: Bearer {{device_token}},
Content-Type: application/json
}
body: none
auth: inherit
}
assert {
res.status == 200
res.body.uuid != null
res.body.title != null
res.body.progress != null
headers {
Authorization: Bearer {{device_token}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['Status is 200'] = true;
tests['Has UUID'] = body.uuid !== null;
tests['Has title'] = body.title !== null;
tests['Has progress'] = body.progress !== null;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Get Book Metadata
Retrieves metadata for a specific book from the KOReader sync endpoint.
**Method:** GET
**Endpoint:** /api/sync/koreader/metadata/{book_uuid}
**Authentication:** Bearer token (device token)
**Path Parameters:**
- `book_uuid` (string): Book UUID
**Response:**
- `uuid` (string): Book UUID
- `title` (string): Book title
- `progress` (object): Reading progress data
- `metadata` (object): Additional book metadata
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Book not found
- 500: Internal server error
}
+44 -8
View File
@@ -6,14 +6,50 @@ meta {
get {
url: {{baseUrl}}/api/sync/koreader/library
headers: {
Authorization: Bearer {{device_token}},
Content-Type: application/json
}
body: none
auth: inherit
}
assert {
res.status == 200
res.body.library_sync != null
res.body.total_books >= 0
headers {
Authorization: Bearer {{device_token}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['Status is 200'] = res.getStatus() === 200;
tests['Has library_sync'] = body.library_sync != null;
tests('Total books >= 0', body.total_books >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Get Library
Retrieves the user's library for KOReader sync operations.
**Method:** GET
**Endpoint:** /api/sync/koreader/library
**Authentication:** Bearer token (device token)
**Response:**
- `library_sync` (object): Library sync data
- `total_books` (number): Total number of books
- `books` (array): Array of book objects
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
@@ -1,12 +1,22 @@
meta {
name: "KOReader Sync Annotations - Per-Annotation SHA-256"
name: KOReader Sync Annotations - Per-Book SHA-256
type: http
seq: 4
}
post {
url: {{base_url}}/api/v1/koreader/sync/bookmarks
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{koreader_device_token}}
Content-Type: application/json
}
body:json {
{
"book_uuid": "{{book_uuid}}",
"highlights": [
{
@@ -26,14 +36,51 @@ post {
"book_sha256": "{{another_book_sha256}}"
}
]
})
auth: {
type: bearer
bearer: {{koreader_device_token}}
}
}
assert {
response.status == 200
response.body.highlights_synced >= 0
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Highlights synced >= 0', body.highlights_synced >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Sync Annotations - Per-Book SHA-256
Synchronizes annotations (highlights) from KOReader with per-annotation SHA-256 hashes for multi-book sync.
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/bookmarks
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `book_uuid` (string): Primary book UUID
- `highlights` (array): Array of highlight objects
- `text` (string): Highlighted text
- `pos0`, `pos1` (string): EPUB CFI positions
- `color` (string): Highlight color (hex)
- `page` (number): Page number
- `book_sha256` (string): SHA-256 hash for this specific book
**Response:**
- `highlights_synced` (number): Number of highlights synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
**Note:** Each highlight can include its own book_sha256, allowing annotations from multiple books in a single request.
}
+53 -10
View File
@@ -1,12 +1,22 @@
meta {
name: "KOReader Sync Bookmarks - Enhanced with SHA-256"
name: KOReader Sync Bookmarks - SHA-256
type: http
seq: 3
}
post {
url: {{base_url}}/api/v1/koreader/sync/bookmarks
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{koreader_device_token}}
Content-Type: application/json
}
body:json {
{
"book_sha256": "{{book_sha256}}",
"bookmarks": [
{
@@ -33,15 +43,48 @@ post {
"page": 50
}
]
})
auth: {
type: bearer
bearer: {{koreader_device_token}}
}
}
assert {
response.status == 200
response.body.sync_status == "completed"
response.body.total_synced >= 0
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Sync completed', body.sync_status === "completed");
tests('Total synced >= 0', body.total_synced >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Sync Bookmarks - SHA-256
Synchronizes bookmarks, notes, and highlights from KOReader using SHA-256 hash for book identification.
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/bookmarks
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `book_sha256` (string): SHA-256 hash of book file
- `bookmarks` (array): Array of bookmarks
- `notes` (array): Array of notes
- `highlights` (array): Array of highlights
**Response:**
- `sync_status` (string): Sync status
- `total_synced` (number): Total items synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
+61 -9
View File
@@ -6,7 +6,17 @@ meta {
post {
url: {{baseUrl}}/api/sync/koreader/bookmarks
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{device_token}}
Content-Type: application/json
}
body:json {
{
"book_uuid": "{{book_uuid}}",
"bookmarks": [
{
@@ -46,15 +56,57 @@ post {
"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
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['Sync completed'] = body.sync_status === "completed";
tests('Items synced >= 0', body.total_synced >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Sync Bookmarks
Synchronizes bookmarks, notes, and highlights from a KOReader device.
**Method:** POST
**Endpoint:** /api/sync/koreader/bookmarks
**Authentication:** Bearer token (device token)
**Request Body:**
- `book_uuid` (string): Book UUID
- `bookmarks` (array): Array of bookmark objects
- `notes` (array): Array of note objects
- `highlights` (array): Array of highlight objects
Each object includes:
- `chapter` (number): Chapter number
- `datetime` (string): ISO 8601 timestamp
- `text` (string): Highlighted/bookmarked text
- `pos0`, `pos1` (string): EPUB CFI positions
- `page` (number): Page number
- `type` (string): Type (highlight, bookmark, note)
- `percentage` (number): Position in book (0-1)
**Response:**
- `sync_status` (string): Sync status (completed, partial)
- `total_synced` (number): Number of items synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
@@ -1,12 +1,22 @@
meta {
name: "KOReader Sync Progress - Priority Matching (SHA-256 only)"
name: KOReader Sync Progress - SHA-256 Only
type: http
seq: 2
}
post {
url: {{base_url}}/api/v1/koreader/sync/progress
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{koreader_device_token}}
Content-Type: application/json
}
body:json {
{
"sync_mode": "immediate",
"books": [
{
@@ -17,13 +27,50 @@ post {
"total_pages": 200
}
]
})
auth: {
type: bearer
bearer: {{koreader_device_token}}
}
}
assert {
response.status == 200 || response.status == 202
script:post-response {
function onResponse(res) {
tests('Status is 200 or 202', res.getStatus() === 200 || res.getStatus() === 202);
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Sync Progress - SHA-256 Only
Synchronizes reading progress using only SHA-256 hash for book identification (when UUID is not available).
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/progress
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `sync_mode` (string): Sync mode (immediate, deferred)
- `books` (array): Array of book progress objects
- `sha256` (string): SHA-256 hash of book file
- `file_path` (string): Path to book file
- `percentage` (number): Progress percentage
- `page` (number): Current page
- `total_pages` (number): Total pages
**Response:**
- `sync_status` (string): Sync status
- `books_synced` (number): Number of books synced
**Status Codes:**
- 200: Success
- 202: Accepted
- 401: Unauthorized
- 500: Internal server error
**Note:** Use this when book UUID is not available, falling back to SHA-256 hash for identification.
}
+64 -10
View File
@@ -1,12 +1,22 @@
meta {
name: "KOReader Sync Progress - Enhanced with SHA-256"
name: KOReader Sync Progress - SHA-256
type: http
seq: 1
}
post {
url: {{base_url}}/api/v1/koreader/sync/progress
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{koreader_device_token}}
Content-Type: application/json
}
body:json {
{
"sync_mode": "immediate",
"books": [
{
@@ -23,15 +33,59 @@ post {
"authors": ["J.R.R. Tolkien"]
}
]
})
auth: {
type: bearer
bearer: {{koreader_device_token}}
}
}
assert {
response.status == 200 || response.status == 202
response.body.sync_status exists()
response.body.books_synced >= 0
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200 || res.getStatus() === 202) {
const body = res.getBody();
tests['Status accepted'] = res.getStatus() === 200 || res.getStatus() === 202;
tests('Has sync_status', body.sync_status !== undefined);
tests('Books synced >= 0', body.books_synced >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Sync Progress - SHA-256
Synchronizes reading progress from a KOReader device using SHA-256 book hash for identification.
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/progress
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `sync_mode` (string): Sync mode (immediate, deferred)
- `books` (array): Array of book progress objects
- `uuid` (string): Book UUID
- `sha256` (string): SHA-256 hash of book file for identification
- `file_path` (string): Path to book file on device
- `percentage` (number): Progress percentage (0-1)
- `chapter` (number): Current chapter
- `page` (number): Current page
- `total_pages` (number): Total pages
- `epubcfi` (string): EPUB location
- `last_read` (string): ISO 8601 timestamp
- `title` (string): Book title
- `authors` (array): List of authors
**Response:**
- `sync_status` (string): Sync status
- `books_synced` (number): Number of books synced
**Status Codes:**
- 200: Success
- 202: Accepted - processing
- 401: Unauthorized
- 500: Internal server error
}
+65 -9
View File
@@ -6,7 +6,17 @@ meta {
post {
url: {{baseUrl}}/api/sync/koreader/progress
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{device_token}}
Content-Type: application/json
}
body:json {
{
"library_id": null,
"books": [
{
@@ -28,15 +38,61 @@ post {
"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
script:post-response {
function onResponse(res) {
if (res.getStatus() === 202) {
const body = res.getBody();
tests['Status is 202'] = res.getStatus() === 202;
tests['Sync status accepted'] = body.sync_status === "accepted";
tests('Books synced >= 0', body.books_synced >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## KOReader Sync Progress
Synchronizes reading progress from a KOReader device to the Bookhoard server.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token (device token)
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Array of book progress objects
- `uuid` (string): Book UUID
- `title` (string): Book title
- `authors` (array): List of authors
- `progress` (number): Progress value
- `percentage` (number): Percentage complete (0-1)
- `last_read` (string): ISO 8601 timestamp
- `chapter` (number): Current chapter
- `epubcfi` (string): EPUB Canonical Fragment Identifier
- `page` (number): Current page
- `total_pages` (number): Total pages
- `sync_mode` (string): Sync mode (immediate, deferred)
- `device_info` (object): Device information
- `koreader_version` (string): KOReader version
- `device_model` (string): Device model identifier
**Response:**
- `sync_status` (string): Sync status (accepted, processing)
- `books_synced` (number): Number of books synced
**Status Codes:**
- 202: Accepted - sync queued
- 401: Unauthorized
- 500: Internal server error
}
+36 -9
View File
@@ -1,15 +1,42 @@
meta {
name: EPUB Download
name: Download EPUB Book
type: http
seq: 1
}
GET {{baseURL}}/api/books/{{bookUUID}}/download
{
"meta": {
"name": "Download EPUB Book",
"description": "Download an EPUB book file from Bookhoard server (no auth required - for Kobo device download)",
"documentation": "Kobo devices can download books directly from Bookhoard using this endpoint. The endpoint returns the book file with appropriate Content-Type headers."
}
get {
url: {{baseURL}}/api/books/{{bookUUID}}/download
body: none
auth: none
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Download EPUB Book
Download an EPUB book file from Bookhoard server. No authentication required for Kobo device downloads.
**Method:** GET
**Endpoint:** /api/books/{bookUUID}/download
**Authentication:** None (for Kobo device downloads)
**Path Parameters:**
- `bookUUID` (string): Book UUID
**Response:** Binary EPUB file data
**Response Headers:**
- `Content-Type`: application/epub+zip
**Status Codes:**
- 200: Success - EPUB file returned
- 404: Book not found
**Note:** This endpoint is designed for Kobo devices to download books directly from Bookhoard. The endpoint returns the book file with appropriate Content-Type headers.
}
@@ -1,35 +1,71 @@
{
"meta": {
"name": "Download Book KEPUB (On-the-fly Conversion)",
"type": "http",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// Test format-specific hash header",
"const kepubHash = resp.headers.get('X-Bookhoard-KEPUB-SHA256');",
"if (kepubHash) {",
" tests['KEPUB hash present'] = true;",
" tests['Hash is 64 chars'] = kepubHash.length === 64;",
"} else {",
" tests['KEPUB hash present'] = false;",
"}",
"",
"// Verify Bookhoard UUID header",
"const bookhoardUUID = resp.headers.get('X-Bookhoard-UUID');",
"tests['Bookhoard UUID present'] = bookhoardUUID !== null;",
"",
"// Verify content type",
"const contentType = resp.headers.get('Content-Type');",
"tests['Content-Type is KEPUB'] = contentType && contentType.includes('kepub');"
]
}
}
]
},
"req": {
"url": "{{baseUrl}}/opds/devices/{{deviceId}}/download/{{mediaItemId}}?format=kepub",
"method": "GET"
meta {
name: Download Book KEPUB (On-the-fly Conversion)
type: http
seq: 1
}
get {
url: {{baseUrl}}/opds/devices/{{deviceId}}/download/{{mediaItemId}}?format=kepub
body: none
auth: inherit
}
script:post-response {
function onResponse(res) {
const headers = res.getHeaders();
const kepubHash = headers.get('X-Bookhoard-KEPUB-SHA256');
if (kepubHash) {
tests['KEPUB hash present'] = true;
tests['Hash is 64 chars'] = kepubHash.length === 64;
} else {
tests['KEPUB hash present'] = false;
}
const bookhoardUUID = headers.get('X-Bookhoard-UUID');
tests['Bookhoard UUID present'] = bookhoardUUID !== null;
const contentType = headers.get('Content-Type');
tests['Content-Type is KEPUB'] = contentType && contentType.includes('kepub');
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Download Book KEPUB (On-the-fly Conversion)
Downloads a book in Kobo EPUB (KEPUB) format with on-the-fly conversion if needed.
**Method:** GET
**Endpoint:** /opds/devices/{deviceId}/download/{mediaItemId}?format=kepub
**Authentication:** Bearer token
**Path Parameters:**
- `deviceId` (string): Device UUID
- `mediaItemId` (string): Media item UUID
**Query Parameters:**
- `format` (string): Must be "kepub"
**Response Headers:**
- `X-Bookhoard-KEPUB-SHA256` (string): SHA-256 hash of the KEPUB file (64 chars)
- `X-Bookhoard-UUID` (string): Bookhoard UUID for the media item
- `Content-Type` (string): Will be "application/kepub+json" or similar
**Response Body:** Binary KEPUB file data
**Status Codes:**
- 200: Success - KEPUB file returned
- 401: Unauthorized
- 404: Media item or device not found
- 500: Internal server error or conversion failure
**Note:** The KEPUB format adds special Kobo-specific markup to enhance reading features on Kobo devices. The file is converted on-the-fly if the source is not already KEPUB.
}
+9 -13
View File
@@ -7,35 +7,31 @@ meta {
delete {
url: {{base_url}}/api/queue/devices/{{device_id}}/clear
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## Clear Device Queue
Clears all queue items for a specific device.
**Method:** DELETE
**Endpoint:** /api/queue/devices/:device_id/clear
**Authentication:** Bearer token
**Path Parameters:**
- `device_id` (string): Device UUID
**Response:**
- Success message with count of cleared items
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
**Example Response:**
```json
{
+9 -13
View File
@@ -7,35 +7,31 @@ meta {
delete {
url: {{base_url}}/api/queue/items/{{item_id}}
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## Delete Queue Item
Deletes a queue item from the sync queue.
**Method:** DELETE
**Endpoint:** /api/queue/items/:item_id
**Authentication:** Bearer token
**Path Parameters:**
- `item_id` (string): Queue item UUID
**Response:**
- Success message confirming deletion
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Queue item not found
**Example Response:**
```json
{
@@ -0,0 +1,71 @@
meta {
name: Filter by Status - Pending
type: http
seq: 3
}
docs {
Filter queue items by status - show only pending items.
**Endpoint**: GET /queue/items?status=pending
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| status | string | Yes | Status filter: pending, processing, completed, failed |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| items | array | List of queue items with pending status |
| total | int | Total matching items |
## Example Request
```
GET /queue/items?status=pending
```
## Example Response
```json
{
"items": [...],
"total": 15
}
```
## Error Responses
| Code | Description |
|------|-------------|
| 401 | Unauthorized |
| 500 | Internal server error |
## Notes
- Only returns items with the specified status
}
get {
url: {{base_url}}/queue/items?status=pending
body: none
auth: inherit
}
token: {{jwt_token}}
}
tests {
test("status must be 200", function() {
expect(res.status).to.eql(200);
});
}
settings {
encodeUrl: true
timeout: 0
}
+9 -13
View File
@@ -7,35 +7,31 @@ meta {
get {
url: {{base_url}}/api/queue/devices/{{device_id}}/stats
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## Get Device Queue Stats
Retrieves statistics for a specific device's sync queue.
**Method:** GET
**Endpoint:** /api/queue/devices/:device_id/stats
**Authentication:** Bearer token
**Path Parameters:**
- `device_id` (string): Device UUID
**Response:**
- Queue statistics including pending, completed, and failed counts
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
**Example Response:**
```json
{
+8 -12
View File
@@ -7,32 +7,28 @@ meta {
get {
url: {{base_url}}/api/queue/items
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## List All Queue Items (Admin)
Retrieves all queue items across all devices (admin only).
**Method:** GET
**Endpoint:** /api/queue/items
**Authentication:** Bearer token (admin role required)
**Response:**
- Array of queue items with device and status information
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Forbidden - admin role required
**Example Response:**
```json
[
+9 -13
View File
@@ -7,35 +7,31 @@ meta {
get {
url: {{base_url}}/api/queue/devices/{{device_id}}/items
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## List Device Queue Items
Retrieves all queue items for a specific device.
**Method:** GET
**Endpoint:** /api/queue/devices/:device_id/items
**Authentication:** Bearer token
**Path Parameters:**
- `device_id` (string): Device UUID
**Response:**
- Array of queue items for the specified device
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
**Example Response:**
```json
[
@@ -0,0 +1,65 @@
meta {
name: List Queue Items (With Pagination)
type: http
seq: 2
}
docs {
List items in the sync queue with pagination.
**Endpoint**: GET /queue/items
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| limit | int | No | Items per page |
| offset | int | No | Pagination offset |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| items | array | List of queue items |
| total | int | Total number of items |
| page | int | Current page number |
| per_page | int | Items per page |
## Example Request
```
GET /queue/items?limit=50&offset=0
```
## Error Responses
| Code | Description |
|------|-------------|
| 401 | Unauthorized |
| 500 | Internal server error |
## Notes
- Supports pagination for large queue lists
}
get {
url: {{base_url}}/queue/items?limit=50&offset=0
body: none
auth: inherit
}
token: {{jwt_token}}
}
tests {
test("status must be 200", function() {
expect(res.status).to.eql(200);
});
}
settings {
encodeUrl: true
timeout: 0
}
View File
+9 -13
View File
@@ -7,36 +7,32 @@ meta {
post {
url: {{base_url}}/api/queue/items/{{item_id}}/retry
body: none
auth: bearer
}
headers: {
Authorization: Bearer {{token}}
auth: inherit
}
docs {
## Retry Queue Item
Retries a failed queue item.
**Method:** POST
**Endpoint:** /api/queue/items/:item_id/retry
**Authentication:** Bearer token
**Path Parameters:**
- `item_id` (string): Queue item UUID
**Response:**
- Success message indicating retry initiated
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Queue item not found
- 400: Invalid item status
**Example Response:**
```json
{
+51 -9
View File
@@ -1,19 +1,61 @@
meta {
name: "Download Device Sidecar File"
name: Download Device Sidecar File
type: http
seq: 2
}
get {
url: {{base_url}}/api/devices/{{device_id}}/sidecar/download
auth: {
type: bearer
bearer: {{user_token}}
}
body: none
auth: inherit
}
assert {
response.status == 200
response.headers["Content-Type"] contains "application/json"
response.headers["Content-Disposition"] contains ".bookhoard.json"
headers {
Authorization: Bearer {{user_token}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const headers = res.getHeaders();
const contentType = headers.get("Content-Type");
const contentDisposition = headers.get("Content-Disposition");
tests('Content-Type is JSON', contentType && contentType.includes("application/json"));
tests('Has .bookhoard.json filename', contentDisposition && contentDisposition.includes(".bookhoard.json"));
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Download Device Sidecar File
Downloads the sidecar configuration file for a device in JSON format.
**Method:** GET
**Endpoint:** /api/devices/{device_id}/sidecar/download
**Authentication:** Bearer token
**Path Parameters:**
- `device_id` (string): Device UUID
**Response Headers:**
- `Content-Type`: application/json
- `Content-Disposition`: attachment; filename="device.bookhoard.json"
**Response Body:** JSON sidecar configuration file
**Status Codes:**
- 200: Success - file returned
- 401: Unauthorized
- 404: Device not found
- 500: Internal server error
}
+51 -11
View File
@@ -1,21 +1,61 @@
meta {
name: "Get Device Sidecar Config"
name: Get Device Sidecar Config
type: http
seq: 1
}
get {
url: {{base_url}}/api/devices/{{device_id}}/sidecar
auth: {
type: bearer
bearer: {{user_token}}
}
body: none
auth: inherit
}
assert {
response.status == 200
response.body.version == "1.0"
response.body.bookhoard exists()
response.body.books exists()
response.body.collections exists()
headers {
Authorization: Bearer {{user_token}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Version is 1.0', body.version === "1.0");
tests('Has bookhoard config', body.bookhoard !== undefined);
tests('Has books array', body.books !== undefined);
tests('Has collections array', body.collections !== undefined);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Device Sidecar Config
Retrieves sidecar configuration for a specific device.
**Method:** GET
**Endpoint:** /api/devices/{device_id}/sidecar
**Authentication:** Bearer token
**Path Parameters:**
- `device_id` (string): Device UUID
**Response:**
- `version` (string): Sidecar version (e.g., "1.0")
- `bookhoard` (object): Bookhoard configuration
- `books` (array): Array of book configurations
- `collections` (array): Array of collection configurations
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
- 500: Internal server error
}
+47 -10
View File
@@ -1,20 +1,57 @@
meta {
name: "Get System Configuration"
name: Get System Configuration
type: http
seq: 3
}
get {
url: {{base_url}}/api/system/config
auth: {
type: bearer
bearer: {{admin_token}}
}
body: none
auth: inherit
}
assert {
response.status == 200
response.body.base_url exists()
response.body.opds_base_url exists()
response.body.api_base_url exists()
headers {
Authorization: Bearer {{admin_token}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Has base_url', body.base_url !== undefined);
tests('Has opds_base_url', body.opds_base_url !== undefined);
tests('Has api_base_url', body.api_base_url !== undefined);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get System Configuration
Retrieves system-wide configuration settings.
**Method:** GET
**Endpoint:** /api/system/config
**Authentication:** Bearer token (admin only)
**Response:**
- `base_url` (string): Base URL
- `opds_base_url` (string): OPDS endpoint URL
- `api_base_url` (string): API endpoint URL
- Additional configuration fields
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Forbidden (admin only)
- 500: Internal server error
}
+53 -9
View File
@@ -1,23 +1,67 @@
meta {
name: "Update System Configuration"
name: Update System Configuration
type: http
seq: 4
}
put {
url: {{base_url}}/api/system/config
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{admin_token}}
Content-Type: application/json
}
body:json {
{
"base_url": "https://bookhoard.example.com",
"opds_base_url": "https://bookhoard.example.com/opds",
"api_base_url": "https://bookhoard.example.com/api"
})
auth: {
type: bearer
bearer: {{admin_token}}
}
}
assert {
response.status == 200
response.body.status == "success"
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Status is success', body.status === "success");
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Update System Configuration
Updates system-wide configuration settings for the Bookhoard instance.
**Method:** PUT
**Endpoint:** /api/system/config
**Authentication:** Bearer token (admin only)
**Request Body:**
- `base_url` (string): Base URL for the instance
- `opds_base_url` (string): OPDS endpoint base URL
- `api_base_url` (string): API endpoint base URL
**Response:**
- `status` (string): Update status
- `config` (object): Updated configuration
**Status Codes:**
- 200: Success
- 400: Invalid configuration
- 401: Unauthorized
- 403: Forbidden (admin only)
- 500: Internal server error
}
+56 -22
View File
@@ -1,24 +1,58 @@
{
"meta": {
"name": "Auto-Link Unlinked Books",
"type": "http"
},
"req": {
"url": "{{baseUrl}}/sync/auto-link-books",
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
],
"body": {
"confidence_threshold": 0.8,
"limit": 50
}
meta {
name: Auto-Link Unlinked Books
type: http
seq: 1
}
post {
url: {{baseUrl}}/sync/auto-link-books
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"confidence_threshold": 0.8,
"limit": 50
}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Auto-Link Unlinked Books
Automatically links unlinked books to media items based on title and author matching with a configurable confidence threshold.
**Method:** POST
**Endpoint:** /sync/auto-link-books
**Authentication:** Bearer token
**Request Body:**
- `confidence_threshold` (number, optional): Minimum confidence score for auto-linking (0-1, default: 0.8)
- `limit` (number, optional): Maximum number of books to auto-link (default: 50)
**Response:**
- `results` (array): Results for each auto-link attempt
- `total` (number): Total number of books processed
- `success` (number): Number of successful links
- `failed` (number): Number of failed links
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 500: Internal server error
**Note:** Higher confidence thresholds produce fewer but more accurate matches. Consider the tradeoff between automation and accuracy.
}
+64 -28
View File
@@ -1,34 +1,70 @@
{
"meta": {
"name": "Bulk Link Unlinked Books",
"type": "http"
},
"req": {
"url": "{{baseUrl}}/sync/bulk-link-books",
"method": "POST",
"header": [
meta {
name: Bulk Link Unlinked Books
type: http
seq: 1
}
post {
url: {{baseUrl}}/sync/bulk-link-books
body: json
auth: inherit
}
headers {
Content-Type: application/json
Authorization: Bearer {{authToken}}
}
body:json {
{
"links": [
{
"key": "Content-Type",
"value": "application/json"
"unlinked_book_id": "{{unlinkedBookId1}}",
"media_item_id": "{{mediaItemId1}}",
"confidence_score": 1.0
},
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
"unlinked_book_id": "{{unlinkedBookId2}}",
"media_item_id": "{{mediaItemId2}}",
"confidence_score": 0.9
}
],
"body": {
"links": [
{
"unlinked_book_id": "{{unlinkedBookId1}}",
"media_item_id": "{{mediaItemId1}}",
"confidence_score": 1.0
},
{
"unlinked_book_id": "{{unlinkedBookId2}}",
"media_item_id": "{{mediaItemId2}}",
"confidence_score": 0.9
}
]
}
]
}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Bulk Link Unlinked Books
Links multiple unlinked books to media items in a single request.
**Method:** POST
**Endpoint:** /sync/bulk-link-books
**Authentication:** Bearer token
**Request Body:**
- `links` (array): Array of link objects
- `unlinked_book_id` (string): Unlinked book UUID
- `media_item_id` (string): Media item UUID to link to
- `confidence_score` (number): Match confidence (0-1)
**Response:**
- `results` (array): Results for each link attempt
- `total` (number): Total number of links processed
- `success` (number): Number of successful links
- `failed` (number): Number of failed links
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 500: Internal server error
**Note:** Use this endpoint after reviewing suggestions from the Get Unlinked Book Suggestions endpoint.
}
@@ -1,16 +1,50 @@
{
"meta": {
"name": "Get Unlinked Book Suggestions",
"type": "http"
},
"req": {
"url": "{{baseUrl}}/sync/unlinked-books/{{unlinkedBookId}}/suggestions",
"method": "GET",
"header": [
{
"key": "Authorization",
"value": "Bearer {{authToken}}"
}
]
}
meta {
name: Get Unlinked Book Suggestions
type: http
seq: 1
}
get {
url: {{baseUrl}}/sync/unlinked-books/{{unlinkedBookId}}/suggestions
body: none
auth: inherit
}
headers {
Authorization: Bearer {{authToken}}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Unlinked Book Suggestions
Retrieves suggested media items from the library that match an unlinked book, enabling manual linking.
**Method:** GET
**Endpoint:** /sync/unlinked-books/{unlinkedBookId}/suggestions
**Authentication:** Bearer token
**Path Parameters:**
- `unlinkedBookId` (string): Unlinked book UUID
**Response:**
- Array of suggested media items with:
- `id` (string): Media item UUID
- `title` (string): Media item title
- `author` (string): Media item author
- `confidence_score` (number): Match confidence (0-1)
- `match_reasons` (array): Reasons for the suggestion
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Unlinked book not found
**Note:** Suggestions are generated based on title similarity, author matching, and other metadata comparisons.
}
+49 -10
View File
@@ -1,5 +1,5 @@
meta {
name: "Get Unlinked Books - User View"
name: Get Unlinked Books - User View
type: http
seq: 4
}
@@ -7,15 +7,54 @@ meta {
get {
url: {{base_url}}/api/sync/unlinked-books
body: none
auth: {
type: bearer
bearer: {{user_token}}
}
auth: inherit
}
assert {
response.status == 200
response.body.unlinked exists()
response.body.total exists()
response.body.total >= 0
headers {
Authorization: Bearer {{user_token}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests('Has unlinked array', body.unlinked !== undefined);
tests('Has total count', body.total !== undefined);
tests('Total >= 0', body.total >= 0);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Unlinked Books - User View
Retrieves all unlinked books for the authenticated user that need manual linking.
**Method:** GET
**Endpoint:** /api/sync/unlinked-books
**Authentication:** Bearer token
**Response:**
- `unlinked` (array): Array of unlinked book objects
- `id` (string): Unlinked book UUID
- `title` (string): Book title
- `author` (string): Book author
- `device_id` (string): Source device ID
- `device_name` (string): Source device name
- `detected_at` (string): Detection timestamp
- `total` (number): Total count of unlinked books
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
}
+58 -12
View File
@@ -1,26 +1,72 @@
meta {
name: "Link Unlinked Book - Manual Resolution"
name: Link Unlinked Book - Manual Resolution
type: http
seq: 5
}
post {
url: {{base_url}}/api/sync/link-book
body: json({
body: json
auth: inherit
}
headers {
Authorization: Bearer {{user_token}}
Content-Type: application/json
}
body:json {
{
"unlinked_book_id": "{{unlinked_book_id}}",
"media_item_id": "{{media_item_id}}",
"confidence_score": 1.0
})
auth: {
type: bearer
bearer: {{user_token}}
}
}
assert {
response.status == 200
response.body.status == "linked"
response.body.unlinked_book_id exists()
response.body.media_item_id exists()
response.body.message exists()
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['Status is linked'] = body.status === "linked";
tests('Has unlinked_book_id', body.unlinked_book_id !== undefined);
tests('Has media_item_id', body.media_item_id !== undefined);
tests('Has message', body.message !== undefined);
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Link Unlinked Book - Manual Resolution
Manually links an unlinked book to a media item in the library.
**Method:** POST
**Endpoint:** /api/sync/link-book
**Authentication:** Bearer token
**Request Body:**
- `unlinked_book_id` (string): Unlinked book UUID
- `media_item_id` (string): Media item UUID to link to
- `confidence_score` (number): Match confidence (0-1, 1.0 for manual)
**Response:**
- `status` (string): Link status (linked)
- `unlinked_book_id` (string): Unlinked book UUID
- `media_item_id` (string): Media item UUID
- `message` (string): Success message
**Status Codes:**
- 200: Success - book linked
- 400: Invalid request
- 401: Unauthorized
- 404: Book or media item not found
- 500: Internal server error
}
@@ -1,21 +1,68 @@
meta {
name: "Get Progress History"
type: "http"
name: Get Progress History
type: http
seq: 3
}
get {
url: "{{baseUrl}}/api/progress/{{mediaItemId}}/history"
body: null
headers: {
Authorization: "Bearer {{jwt}}"
Content-Type: "application/json"
}
url: {{baseUrl}}/api/progress/{{mediaItemId}}/history
body: none
auth: inherit
}
tests {
assertions {
assert response.status == 200
assert hasKey(response.body, "sessions")
headers {
Authorization: Bearer {{jwt}}
Content-Type: application/json
}
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['Has sessions key'] = body.sessions !== undefined;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Progress History
Retrieves historical reading progress sessions for a specific media item.
**Method:** GET
**Endpoint:** /api/progress/{mediaItemId}/history
**Authentication:** Bearer token
**Path Parameters:**
- `mediaItemId` (string): Media item UUID
**Query Parameters:**
- `limit` (number, optional): Maximum number of sessions to return
- `offset` (number, optional): Offset for pagination
**Response:**
- `sessions` (array): Array of reading sessions
- `session_id` (string): Session UUID
- `start_time` (string): Session start timestamp
- `end_time` (string): Session end timestamp
- `start_percentage` (number): Progress at start
- `end_percentage` (number): Progress at end
- `device_id` (string): Device used
- `duration_seconds` (number): Session duration
- `total_sessions` (number): Total number of sessions
- `total_reading_time` (number): Total reading time in seconds
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Media item not found
- 500: Internal server error
}
@@ -1,14 +1,55 @@
meta {
name: "Get Universal Progress"
type: "http"
name: Get Universal Progress
type: http
seq: 1
}
get {
url: "{{baseUrl}}/api/progress/{{mediaItemId}}"
body: null
headers: {
Authorization: "Bearer {{jwt}}"
Content-Type: "application/json"
}
url: {{baseUrl}}/api/progress/{{mediaItemId}}
body: none
auth: inherit
}
headers {
Authorization: Bearer {{jwt}}
Content-Type: application/json
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Get Universal Progress
Retrieves universal reading progress for a specific media item across all devices.
**Method:** GET
**Endpoint:** /api/progress/{mediaItemId}
**Authentication:** Bearer token
**Path Parameters:**
- `mediaItemId` (string): Media item UUID
**Response:**
- `media_item_id` (string): Media item UUID
- `progress` (object): Universal progress data
- `percentage` (number): Overall progress percentage
- `epubcfi` (string): EPUB location
- `page` (number): Current page
- `chapter` (number): Current chapter
- `devices` (array): Per-device progress data
- `device_id` (string): Device UUID
- `device_name` (string): Device name
- `progress` (object): Device-specific progress
- `last_updated` (string): Last update timestamp
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Media item not found
- 500: Internal server error
}
@@ -1,12 +1,22 @@
meta {
name: "Update Universal Progress"
type: "http"
name: Update Universal Progress
type: http
seq: 2
}
post {
url: "{{baseUrl}}/api/progress/{{mediaItemId}}"
body: {
url: {{baseUrl}}/api/progress/{{mediaItemId}}
body: json
auth: inherit
}
headers {
Authorization: Bearer {{jwt}}
Content-Type: application/json
}
body:json {
{
"source": "web",
"location": {
"percentage": 0.45,
@@ -18,16 +28,59 @@ post {
"user_agent": "integration-test"
}
}
headers: {
Authorization: "Bearer {{jwt}}"
Content-Type: "application/json"
}
}
tests {
assertions {
assert response.status == 200
assert response.body.sync_status == "success"
assert response.body.progress_updated == true
script:post-response {
function onResponse(res) {
if (res.getStatus() === 200) {
const body = res.getBody();
tests['Sync status success'] = body.sync_status === "success";
tests['Progress updated'] = body.progress_updated === true;
}
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Update Universal Progress
Updates universal reading progress for a media item from a specific device or source.
**Method:** POST
**Endpoint:** /api/progress/{mediaItemId}
**Authentication:** Bearer token
**Path Parameters:**
- `mediaItemId` (string): Media item UUID
**Request Body:**
- `source` (string): Source identifier (web, kobo, koreader, device_id)
- `location` (object): Progress location data
- `percentage` (number): Progress percentage (0-1)
- `page` (number, optional): Current page
- `total_pages` (number, optional): Total pages
- `epubcfi` (string, optional): EPUB location
- `chapter` (number, optional): Current chapter
- `device_metadata` (object): Device metadata
- `device_type` (string): Device type
- `user_agent` (string, optional): User agent string
**Response:**
- `sync_status` (string): Sync status (success, partial)
- `progress_updated` (boolean): Whether progress was updated
- `conflicts_detected` (array, optional): Any conflicts detected
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 404: Media item not found
- 500: Internal server error
}
+56 -84
View File
@@ -1,85 +1,57 @@
{
"meta": {
"name": "Logout User",
"type": "http",
"seq": 1,
"auth": "Inherit"
},
"request": {
"method": "POST",
"header": [
{
"name": "Content-Type",
"value": "application/json"
}
],
"body": {
"type": "json",
"json": {
"refresh_token": "{{refresh_token}}"
}
},
"url": {
"raw": "{{base_url}}/api/auth/logout",
"host": ["{{base_url}}"],
"path": ["api", "auth", "logout"]
},
"description": "Logs out the user by revoking their refresh token. If no refresh token is provided, the request succeeds but no token is revoked."
},
"response": [
{
"name": "Success Response",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"type": "json",
"json": {
"refresh_token": "valid-refresh-token-uuid"
}
},
"url": {
"raw": "{{base_url}}/api/auth/logout",
"host": ["{{base_url}}"],
"path": ["api", "auth", "logout"]
}
},
"status": 200,
"code": 200,
"header": [
{
"name": "content-type",
"value": "application/json"
}
],
"body": "{\n \"message\": \"logged out successfully\"\n}",
"description": "Successfully logged out and refresh token revoked."
},
{
"name": "Logout Without Refresh Token",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"type": "json",
"json": {}
},
"url": {
"raw": "{{base_url}}/api/auth/logout",
"host": ["{{base_url}}"],
"path": ["api", "auth", "logout"]
}
},
"status": 200,
"code": 200,
"header": [
{
"name": "content-type",
"value": "application/json"
}
],
"body": "{\n \"message\": \"logged out successfully\"\n}",
"description": "Logout succeeds even without a refresh token."
}
]
meta {
name: Logout User
type: http
seq: 1
}
post {
url: {{base_url}}/api/auth/logout
body: json
auth: inherit
}
headers {
Content-Type: application/json
}
body:json {
{
"refresh_token": "{{refresh_token}}"
}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Logout User
Logs out the user by revoking their refresh token. If no refresh token is provided, the request succeeds but no token is revoked.
**Method:** POST
**Endpoint:** /api/auth/logout
**Authentication:** Bearer token (optional)
**Request Body:**
- `refresh_token` (string, optional): Refresh token to revoke
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success - user logged out (token revoked if provided)
- 401: Unauthorized
**Example Response:**
```json
{
"message": "logged out successfully"
}
```
**Note:** The access token will expire naturally after 1 hour. The refresh token is immediately revoked on logout, preventing future token refreshes.
}
+67 -86
View File
@@ -1,87 +1,68 @@
{
"meta": {
"name": "Refresh Access Token",
"type": "http",
"seq": 1,
"auth": "Inherit"
},
"request": {
"method": "POST",
"header": [
{
"name": "Content-Type",
"value": "application/json"
}
],
"body": {
"type": "json",
"json": {
"refresh_token": "{{refresh_token}}"
}
},
"url": {
"raw": "{{base_url}}/api/auth/refresh",
"host": ["{{base_url}}"],
"path": ["api", "auth", "refresh"]
},
"description": "Refreshes an access token using a valid refresh token. Returns a new access token with 1-hour expiration."
},
"response": [
{
"name": "Success Response",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"type": "json",
"json": {
"refresh_token": "valid-refresh-token-uuid"
}
},
"url": {
"raw": "{{base_url}}/api/auth/refresh",
"host": ["{{base_url}}"],
"path": ["api", "auth", "refresh"]
}
},
"status": 200,
"code": 200,
"header": [
{
"name": "content-type",
"value": "application/json"
}
],
"body": "{\n \"access_token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600\n}",
"description": "Returns a new access token that expires in 1 hour (3600 seconds)."
},
{
"name": "Invalid Refresh Token",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"type": "json",
"json": {
"refresh_token": "invalid-token"
}
},
"url": {
"raw": "{{base_url}}/api/auth/refresh",
"host": ["{{base_url}}"],
"path": ["api", "auth", "refresh"]
}
},
"status": 401,
"code": 401,
"header": [
{
"name": "content-type",
"value": "application/json"
}
],
"body": "{\n \"error\": \"invalid or expired refresh token\"\n}",
"description": "Returned when the refresh token is invalid, expired, or has been revoked."
}
]
meta {
name: Refresh Access Token
type: http
seq: 1
}
post {
url: {{base_url}}/api/auth/refresh
body: json
auth: inherit
}
headers {
Content-Type: application/json
}
body:json {
{
"refresh_token": "{{refresh_token}}"
}
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## Refresh Access Token
Refreshes an access token using a valid refresh token. Returns a new access token with 1-hour expiration.
**Method:** POST
**Endpoint:** /api/auth/refresh
**Authentication:** Not required (refresh token is in request body)
**Request Body:**
- `refresh_token` (string): Valid refresh token UUID
**Response:**
- `access_token` (string): New JWT access token (1 hour expiration)
- `token_type` (string): Token type (usually "Bearer")
- `expires_in` (number): Token lifetime in seconds (3600)
**Status Codes:**
- 200: Success - new access token generated
- 401: Unauthorized - invalid or expired refresh token
**Example Response (Success):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600
}
```
**Example Response (Invalid Token):**
```json
{
"error": "invalid or expired refresh token"
}
```
**Note:** Access tokens expire after 1 hour. Use the refresh token to obtain a new access token without requiring the user to log in again.
}
+54 -13
View File
@@ -1,5 +1,5 @@
meta {
name: "WebSocket - Connect and Receive Updates"
name: WebSocket - Connect and Receive Updates
type: websocket
seq: 1
}
@@ -9,22 +9,63 @@ vars {
}
websocket {
url: "{{ baseUrl }}/ws/sync?token={{ token }}"
url: {{baseUrl}}/ws/sync?token={{token}}
body: {
type: "ping",
timestamp: "2026-01-30T20:00:00Z"
}
auth: {
type: bearer
token: "{{ token }}"
}
headers: {
Authorization: "Bearer {{ token }}"
}
auth: inherit
}
assert {
res.status == 101
res.headers.contains("Connection", "upgrade")
res.headers.contains("Upgrade", "websocket")
headers {
Authorization: Bearer {{token}}
}
script:post-response {
function onResponse(res) {
tests('Status is 101 (Switching Protocols)', res.getStatus() === 101);
const headers = res.getHeaders();
const connection = headers.get("Connection");
const upgrade = headers.get("Upgrade");
tests('Connection header has upgrade', connection && connection.toLowerCase().includes("upgrade"));
tests('Upgrade header is websocket', upgrade && upgrade.toLowerCase().includes("websocket"));
}
onResponse(res);
}
settings {
encodeUrl: true
timeout: 0
}
docs {
## WebSocket - Connect and Receive Updates
Establishes a WebSocket connection for real-time sync updates.
**Type:** WebSocket
**Endpoint:** /ws/sync?token={token}
**Authentication:** Bearer token (via query parameter)
**Query Parameters:**
- `token` (string): JWT access token
**Request Body:**
- `type` (string): Message type (ping, subscribe, unsubscribe)
- `timestamp` (string): ISO 8601 timestamp
**Expected Response:**
- Status: 101 Switching Protocols
- Headers:
- `Connection`: upgrade
- `Upgrade`: websocket
**Status Codes:**
- 101: Switching Protocols - WebSocket established
- 401: Unauthorized
- 500: Internal server error
**Note:** WebSocket connection allows real-time push notifications for sync updates, progress changes, and device activity.
}