Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d429534b12 | ||
|
|
f70579b4fc | ||
|
|
1f5c5a28bd | ||
|
|
d4c52e9a6a | ||
|
|
acbb6c7981 | ||
|
|
91c8be8562 | ||
|
|
6859f81144 | ||
|
|
078c4b1f3f | ||
|
|
c49a9605ff | ||
|
|
df90938c5c | ||
|
|
ffcdab36a0 | ||
|
|
44a0f8c7a4 | ||
|
|
8ca95db08a | ||
|
|
9a60196f1d | ||
|
|
7274b5196c | ||
|
|
868003331c | ||
|
|
e310fa6a9d | ||
|
|
e35d394736 | ||
|
|
2a7ac881fd | ||
|
|
26f1f98736 | ||
|
|
54d0550dec | ||
|
|
995ccb50bb | ||
|
|
4ab947f7db | ||
|
|
f07c93e582 | ||
|
|
178fb2eb37 | ||
|
|
dafcadd211 | ||
|
|
50ec2bebf2 | ||
|
|
6e9b3528d8 | ||
|
|
97e546b2a4 | ||
|
|
1585aa1073 | ||
|
|
f6e257e497 | ||
|
|
0670d904a0 |
@@ -4,7 +4,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
||||
|
||||
## ✨ Why Bookhoard?
|
||||
|
||||
**🔄 Universal Sync**: Your reading progress, highlights, and notes sync automatically across all your devices - KOReader, Kobo, web, and mobile.
|
||||
**🔄 Universal Sync**: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.
|
||||
|
||||
**📱 Multi-Library**: Organize your ebooks, comics, and manga with per-library folders and smart collections.
|
||||
|
||||
@@ -52,13 +52,13 @@ The first user to register automatically becomes an admin.
|
||||
|
||||
### Universal Cross-Platform Sync
|
||||
|
||||
- **Real-Time Progress**: Turn a page on your Kindle, see it on your phone
|
||||
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
|
||||
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
||||
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
||||
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
||||
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
||||
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
||||
- **Format Conversion**: On-the-fly EPUB→KEPUB for Kobo devices
|
||||
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
|
||||
|
||||
### Media Management
|
||||
|
||||
@@ -74,7 +74,7 @@ The first user to register automatically becomes an admin.
|
||||
### Smart Collections
|
||||
|
||||
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
|
||||
- **Device Shelf Mappings**: Sync collections to Kobo shelves and KOReader categories
|
||||
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
|
||||
- **Test Before Creating**: Preview which books match your rules
|
||||
|
||||
### Library Organization
|
||||
@@ -102,8 +102,8 @@ The first user to register automatically becomes an admin.
|
||||
|
||||
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
|
||||
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
|
||||
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration
|
||||
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
|
||||
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
|
||||
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
|
||||
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
|
||||
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
|
||||
@@ -111,18 +111,18 @@ The first user to register automatically becomes an admin.
|
||||
### For Developers
|
||||
|
||||
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
||||
- **[docs/contributing/DEVELOPMENT.md](docs/contributing/DEVELOPMENT.md)** - Development workflow
|
||||
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Supported Devices
|
||||
|
||||
| Platform | Sync | OPDS | Status |
|
||||
| ---------------- | ---- | ---- | ------------------------ |
|
||||
| **Web Browser** | ✅ | ✅ | Full support |
|
||||
| **KOReader** | ✅ | ✅ | Kindle, Kobo, PocketBook |
|
||||
| **Kobo Devices** | ✅ | ✅ | Clara, Libra, Sage, etc. |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Coming Q2 2026 |
|
||||
| Platform | Sync | OPDS | Status |
|
||||
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
|
||||
| **Web Browser** | ✅ | ✅ | Full support |
|
||||
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
||||
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Android/iOS apps coming later |
|
||||
|
||||
---
|
||||
|
||||
@@ -162,7 +162,7 @@ bruno run
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for guidelines.
|
||||
We welcome contributions! Please see [docs/developer/development.md](docs/developer/development.md) for guidelines.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+133
-16
@@ -26,14 +26,16 @@ Complete API documentation for Bookhoard v1.0 with Universal Cross-Platform Sync
|
||||
8. [Device Management](#device-management)
|
||||
9. [Analytics](#analytics)
|
||||
10. [Book Matching & Linking](#book-matching--linking)
|
||||
11. [Collections](#collections) → See [COLLECTIONS_API.md](COLLECTIONS_API.md)
|
||||
11. [Collections](#collections) → See [Collections API](collections-api.md)
|
||||
12. [OPDS](#opds-open-publication-distribution-system)
|
||||
13. [Sync Protocol - KOReader](#sync-protocol---koreader)
|
||||
14. [Sync Protocol - Kobo](#sync-protocol---kobo)
|
||||
15. [Universal Progress](#universal-progress)
|
||||
16. [Conflicts](#conflicts)
|
||||
17. [Sync Queue](#sync-queue)
|
||||
18. [WebSocket](#websocket)
|
||||
18. [System Settings & Configuration](#system-settings--configuration)
|
||||
19. [Hash Conflicts](#hash-conflicts)
|
||||
20. [WebSocket](#websocket)
|
||||
|
||||
## Base URL
|
||||
|
||||
@@ -201,7 +203,9 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### Update Scan Settings
|
||||
### Update Scan Settings (Legacy)
|
||||
|
||||
> Superseded by `PUT /api/system/settings` (see [System Settings & Configuration](#system-settings--configuration)); kept for backward compatibility.
|
||||
|
||||
```http
|
||||
PUT /api/libraries/scan-settings
|
||||
@@ -665,18 +669,23 @@ Content-Type: application/json
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "uuid",
|
||||
"registration_id": "registration-uuid",
|
||||
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
|
||||
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||
"expires_in": 300
|
||||
"expires_in": 300,
|
||||
"poll_interval": 3,
|
||||
"setup_instructions": {
|
||||
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes.
|
||||
|
||||
### Check Registration Status
|
||||
|
||||
```http
|
||||
POST /api/devices/auth/status
|
||||
POST /api/devices/register/status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -688,13 +697,13 @@ Content-Type: application/json
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending|approved|expired",
|
||||
"status": "pending|approved",
|
||||
"auth_token": "device-bearer-token...",
|
||||
"device_id": "uuid",
|
||||
"sync_endpoints": {
|
||||
"progress": "https://bookhoard.com/api/sync/progress",
|
||||
"metadata": "https://bookhoard.com/api/sync/metadata",
|
||||
"annotations": "https://bookhoard.com/api/sync/annotations"
|
||||
"progress": "https://bookhoard.com/api/sync/koreader/progress",
|
||||
"metadata": "https://bookhoard.com/api/sync/koreader/metadata",
|
||||
"bookmarks": "https://bookhoard.com/api/sync/koreader/bookmarks"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -747,6 +756,22 @@ DELETE /api/devices/{device_id}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Get Device Sidecar Config
|
||||
|
||||
Returns the `.bookhoard.json` sidecar config for a device (server endpoints, books keyed by per-format SHA-256, collections) used by the KOReader plugin to self-configure.
|
||||
|
||||
```http
|
||||
GET /api/devices/{device_id}/sidecar
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Also available as a file download:
|
||||
|
||||
```http
|
||||
GET /api/devices/{device_id}/sidecar/download
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## Analytics
|
||||
|
||||
### Get Reading Statistics
|
||||
@@ -953,7 +978,7 @@ Authorization: Bearer <token>
|
||||
|
||||
## Collections
|
||||
|
||||
For complete collection management documentation, see **[COLLECTIONS_API.md](COLLECTIONS_API.md)**.
|
||||
For complete collection management documentation, see **[Collections API](collections-api.md)**.
|
||||
|
||||
**Quick Reference**:
|
||||
|
||||
@@ -1203,6 +1228,8 @@ Authorization: Bearer <device_token>
|
||||
|
||||
## Sync Protocol - Kobo
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
|
||||
|
||||
### Kobo Markup Sync
|
||||
|
||||
```http
|
||||
@@ -1538,6 +1565,96 @@ Authorization: Bearer <token>
|
||||
}
|
||||
```
|
||||
|
||||
## System Settings & Configuration
|
||||
|
||||
### List All Settings
|
||||
|
||||
Returns every tunable setting with current value and metadata (type, range, category, group, description, `requires_restart`, `is_default`).
|
||||
|
||||
```http
|
||||
GET /api/system/settings
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "scan_poll_interval_seconds",
|
||||
"value": "60",
|
||||
"type": "int",
|
||||
"min": "1",
|
||||
"max": "3600",
|
||||
"requires_restart": false,
|
||||
"category": "scanner",
|
||||
"group": "Scanning",
|
||||
"description": "How often to scan all libraries (seconds)",
|
||||
"is_default": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Update a Setting
|
||||
|
||||
Type-aware validation (int range, bool parse, IANA timezone for `default_timezone`), persists the value, reloads the registry, and reports whether a restart is needed.
|
||||
|
||||
```http
|
||||
PUT /api/system/settings
|
||||
Authorization: Bearer <admin_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"key": "scan_poll_interval_seconds",
|
||||
"value": "30"
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200): the updated entry plus `reload_required`.
|
||||
|
||||
Setting categories: scanner (`scan_poll_interval_seconds`, `auto_scan_enabled`), general (`default_timezone`), security (session duration, password rules, auth rate limit, login lockout), api (OPDS page sizes, device rate limits), sync (annotation tombstone TTL, sync queue interval/batch), performance (conversion cache TTL, worker pool size/capacity). See [System Settings API](api/system/settings.md) for the full catalog.
|
||||
|
||||
### Get / Update Raw System Config
|
||||
|
||||
Flat key/value configuration (e.g. `base_url`), including keys without registry metadata.
|
||||
|
||||
```http
|
||||
GET /api/system/config
|
||||
PUT /api/system/config
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
## Hash Conflicts
|
||||
|
||||
Duplicate content discovered during hashing (import, rescan, or the startup backfill) is grouped into hash conflicts for an explicit keep/merge decision. Files on disk are never deleted.
|
||||
|
||||
### List Hash Conflicts
|
||||
|
||||
```http
|
||||
GET /api/admin/hash-conflicts
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
**Response** (200): `{ "conflicts": [ { id, library_id, library_name, sha256, created_at, items: [ { id, title, author, file_path, file_size, created_at, progress_count, highlight_count, bookmark_count, note_count, collection_count } ] } ], "total": n }`
|
||||
|
||||
### Resolve Hash Conflict
|
||||
|
||||
```http
|
||||
POST /api/admin/hash-conflicts/:id/resolve
|
||||
Authorization: Bearer <admin_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "keep",
|
||||
"keep_uuid": "media-item-uuid-to-keep"
|
||||
}
|
||||
```
|
||||
|
||||
- `action=keep` — merge every other copy's child rows (progress, highlights, bookmarks, notes, collections) into the kept item, then delete the losers
|
||||
- `action=keep_all` — copies are intentional; dismiss the conflict
|
||||
|
||||
**Errors**: `400` (bad ID / missing `keep_uuid`), `404` (not found), `409` (already resolved).
|
||||
|
||||
## WebSocket
|
||||
|
||||
### Connect to WebSocket
|
||||
@@ -1693,10 +1810,10 @@ bruno run bruno/devices/
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [README.md](README.md) - Getting started guide
|
||||
- [UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md](UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md) - Sync architecture
|
||||
- [KOBOREADER_SETUP.md](KOBOREADER_SETUP.md) - KOReader device setup
|
||||
- [KOBO_SETUP.md](KOBO_SETUP.md) - Kobo device setup
|
||||
- [README.md](../../README.md) - Getting started guide
|
||||
- [Sync Guide](../user/sync-guide.md) - Sync concepts and conflict resolution
|
||||
- [KOReader Setup](../user/devices/koreader-setup.md) - KOReader device setup
|
||||
- [Kobo Setup](../user/devices/kobo-setup.md) - Kobo device setup (native sync coming soon)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Hash Conflicts API
|
||||
|
||||
## Overview
|
||||
|
||||
When Bookhoard hashes your library (on import, rescan, or the startup backfill), two media items in the same library with the same `file_sha256` indicate duplicate content. Each duplicate group is recorded as a **hash conflict** and exposed here for an explicit keep/merge decision. Conflicts are also surfaced in the admin UI's Hash Conflicts page.
|
||||
|
||||
**Authentication**: Admin JWT token required
|
||||
**Content-Type**: `application/json` (resolve also accepts form-encoded bodies for htmx)
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Hash Conflicts
|
||||
|
||||
List all pending conflict groups, each with its member items and per-item usage counts (reading progress, highlights, bookmarks, notes, collections) to help decide which copy to keep.
|
||||
|
||||
**Endpoint**: `GET /api/admin/hash-conflicts`
|
||||
|
||||
**Response**: **200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"conflicts": [
|
||||
{
|
||||
"id": "conflict-uuid",
|
||||
"library_id": "library-uuid",
|
||||
"library_name": "Ebooks",
|
||||
"sha256": "abc123...",
|
||||
"created_at": "2026-08-14T12:00:00Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "media-item-uuid",
|
||||
"title": "The Hobbit",
|
||||
"author": "J. R. R. Tolkien",
|
||||
"file_path": "/books/hobbit.epub",
|
||||
"file_size": 1048576,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"progress_count": 2,
|
||||
"highlight_count": 12,
|
||||
"bookmark_count": 3,
|
||||
"note_count": 1,
|
||||
"collection_count": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
curl -X GET https://bookhoard.example.com/api/admin/hash-conflicts \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Resolve Hash Conflict
|
||||
|
||||
Resolve one conflict group.
|
||||
|
||||
**Endpoint**: `POST /api/admin/hash-conflicts/{id}/resolve`
|
||||
|
||||
**Request Body** (JSON or form-encoded):
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "keep",
|
||||
"keep_uuid": "media-item-uuid-to-keep"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----------- | ------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `action` | string | Yes | `keep_all` — both copies are intentional; dismiss the conflict. `keep` — keep `keep_uuid` and delete the other copies. |
|
||||
| `keep_uuid` | string | for `action=keep` | The media item UUID to keep. Must belong to this conflict group. With `keep`, every other copy's child rows (progress, highlights, bookmarks, notes, collections, …) are merged into the kept item before the losers are deleted. |
|
||||
|
||||
**Responses**:
|
||||
|
||||
- `200 OK` — resolved (body is an HTML confirmation snippet for the admin UI page)
|
||||
- `400 Bad Request` — invalid conflict ID, missing `keep_uuid`, or `keep_uuid` not in the group
|
||||
- `404 Not Found` — conflict doesn't exist
|
||||
- `409 Conflict` — conflict already resolved
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
curl -X POST https://bookhoard.example.com/api/admin/hash-conflicts/<id>/resolve \
|
||||
-H "Authorization: Bearer <admin_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action": "keep", "keep_uuid": "media-item-uuid"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When Conflicts Are Created
|
||||
|
||||
- **Startup backfill**: items imported before hashing existed are hashed automatically ~30s after startup; duplicates discovered land here.
|
||||
- **Rescan**: hashes are recomputed and content duplicates are flagged.
|
||||
|
||||
Files on disk are never deleted — resolution only affects database rows.
|
||||
|
||||
---
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
- [System Settings API](../system/settings.md) — scanning configuration
|
||||
- [Scanner API](../scanner/) — triggering scans and watch mode
|
||||
@@ -21,9 +21,10 @@ Complete reference for Bookhoard REST API endpoints.
|
||||
- [Conflicts](conflicts/) - Sync conflict resolution
|
||||
- [Queue](queue/) - Sync queue management
|
||||
- [Scanner](scanner/) - Library scanning and watch mode (admin)
|
||||
- [System](system/) - Tunable system settings and configuration (admin)
|
||||
- [OPDS](opds/) - Open Publication Distribution
|
||||
- [KOReader](koreader/) - KOReader sync protocol
|
||||
- [Kobo](kobo/) - Kobo sync protocol
|
||||
- [Kobo](kobo/) - Kobo sync protocol (coming soon)
|
||||
- [WebSocket](websocket/) - Real-time sync events
|
||||
|
||||
---
|
||||
@@ -51,6 +52,8 @@ See [Admin Operations](admin/)
|
||||
|
||||
- GET /api/auth/users - List all users (admin)
|
||||
- PUT /api/auth/users/:id/max-devices - Update user device limit (admin)
|
||||
- GET /api/admin/hash-conflicts - List pending hash conflict groups (admin) — see [Hash Conflicts](admin/hash-conflicts.md)
|
||||
- POST /api/admin/hash-conflicts/:id/resolve - Resolve a conflict (keep / keep_all) (admin)
|
||||
|
||||
## Users & Profiles
|
||||
|
||||
@@ -71,7 +74,10 @@ See [Library Management](libraries/)
|
||||
- DELETE /api/libraries/:id/folders - Delete library folder (admin)
|
||||
- GET /api/libraries/:id/stats - Get library statistics (admin)
|
||||
- GET /api/libraries/:id/media-items - Get library media items (admin)
|
||||
- GET /api/libraries/browse - Browse server directories (admin)
|
||||
- POST /api/libraries/:id/scan - Scan library (admin)
|
||||
- GET /api/libraries/scan-settings - Legacy scan settings (admin; superseded by /api/system/settings)
|
||||
- PUT /api/libraries/scan-settings - Legacy scan settings update (admin; superseded by /api/system/settings)
|
||||
- GET /api/libraries/visibility - Get visible libraries
|
||||
- POST /api/libraries/visibility - Set library visibility
|
||||
|
||||
@@ -101,6 +107,10 @@ See [Media Item Operations](media-items/)
|
||||
- GET /api/media-items/:id/highlights/:highlightId - Get highlight
|
||||
- PUT /api/media-items/:id/highlights/:highlightId - Update highlight
|
||||
- DELETE /api/media-items/:id/highlights/:highlightId - Delete highlight
|
||||
- GET /api/media-items/:id/bookmarks - Get bookmarks
|
||||
- GET /api/media-items/:id/annotations/deleted - List deleted annotations (history)
|
||||
- POST /api/media-items/:id/annotations/:annotationId/restore - Restore a deleted annotation
|
||||
- DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark - Permanently delete a deleted annotation
|
||||
- POST /api/media-items - Create media item (admin)
|
||||
- PUT /api/media-items/:id - Update media item (admin)
|
||||
- DELETE /api/media-items/:id - Delete media item (admin)
|
||||
@@ -134,10 +144,21 @@ See [Device Registration & Sync](devices/)
|
||||
- GET /api/devices/pending - List pending registrations (admin)
|
||||
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
||||
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
||||
- POST /api/devices/:id/shelves - Add to shelf (Kobo)
|
||||
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
||||
- GET /api/devices/:id/shelves - Get shelf contents
|
||||
- DELETE /api/devices/:id/shelves - Remove from shelf
|
||||
- DELETE /api/devices/:id/shelves/clear - Clear shelf
|
||||
- GET /api/devices/:id/sidecar - Get device sidecar config (.bookhoard.json) — see [Sidecar Config](devices/get_sidecar_config.md)
|
||||
- GET /api/devices/:id/sidecar/download - Download sidecar config as a file
|
||||
|
||||
## System Settings & Configuration
|
||||
|
||||
See [System API](system/)
|
||||
|
||||
- GET /api/system/settings - List all tunable settings with metadata (admin)
|
||||
- PUT /api/system/settings - Validate, persist, and reload a single setting (admin)
|
||||
- GET /api/system/config - Raw key/value system configuration (admin)
|
||||
- PUT /api/system/config - Update raw config values (admin)
|
||||
|
||||
## Analytics
|
||||
|
||||
@@ -220,12 +241,15 @@ See [OPDS Feeds](opds/)
|
||||
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
||||
|
||||
- POST /api/sync/koreader/progress - Sync reading progress
|
||||
- GET /api/sync/koreader/resolve?sha256={hash} - Resolve a book UUID by file SHA-256
|
||||
- GET /api/sync/koreader/metadata/:uuid - Get book metadata
|
||||
- GET /api/sync/koreader/library - Get device library
|
||||
- POST /api/sync/koreader/bookmarks - Sync bookmarks
|
||||
|
||||
## Kobo Sync Protocol
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
|
||||
|
||||
See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
|
||||
|
||||
- POST /api/sync/kobo/markup - Sync markup highlights
|
||||
|
||||
@@ -158,7 +158,7 @@ The frontend toast.js interceptor:
|
||||
- **Backend**: Automatically manages HTTP-only cookie
|
||||
- **Frontend**: Store tokens in localStorage for API calls
|
||||
|
||||
### Mobile Applications
|
||||
### Mobile Applications (coming later)
|
||||
|
||||
- Store access token in secure storage (Keychain/Keystore)
|
||||
- Store refresh token in secure storage
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Check device registration status or get device details.
|
||||
|
||||
**Endpoint**: `POST /api/devices/auth/status` or `GET /api/devices/{device_id}`
|
||||
**Endpoint**: `POST /api/devices/register/status` or `GET /api/devices/{device_id}`
|
||||
**Auth**: Not required for status check, Required for device details
|
||||
**Content-Type**: `application/json` (for status check)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Get Device Sidecar Config
|
||||
|
||||
Returns the KOReader/Kobo sidecar configuration (`.bookhoard.json`) for a device: server endpoints, the user's books (keyed by SHA-256 with UUID fallback), and collections. Used by the Bookhoard KOReader plugin to self-configure after approval.
|
||||
|
||||
**Endpoint**: `GET /api/devices/{id}/sidecar`
|
||||
**Auth**: User JWT (device owner or admin)
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"bookhoard": {
|
||||
"opds_catalog": "https://bookhoard.example.com/opds/devices/<device-id>/catalog",
|
||||
"sync_api": "https://bookhoard.example.com/api/sync/kobo",
|
||||
"opds_base_url": "https://bookhoard.example.com/opds",
|
||||
"api_base_url": "https://bookhoard.example.com",
|
||||
"device_id": "<device-id>",
|
||||
"device_token": "dev_..."
|
||||
},
|
||||
"books": {
|
||||
"abc123sha256...": {
|
||||
"bookhoard_uuid": "media-item-uuid",
|
||||
"title": "The Hobbit",
|
||||
"author": "J. R. R. Tolkien",
|
||||
"available_formats": ["epub", "kepub"],
|
||||
"sha256": "abc123sha256...",
|
||||
"file_path": "/books/hobbit.epub"
|
||||
}
|
||||
},
|
||||
"collections": [
|
||||
{ "name": "Favorites", "shelf_mapping": "Favorites" }
|
||||
],
|
||||
"opds_enabled": true,
|
||||
"sidecar_enabled": true,
|
||||
"last_updated": "2026-08-20T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- The `books` map is keyed by per-format SHA-256 (falling back to the item UUID), so a book downloaded in a different format (e.g. KEPUB) still matches its primary entry. Each entry lists `available_formats` for the item.
|
||||
- `available_formats` includes `kepub` when the source is an EPUB (conversion available).
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl https://bookhoard.example.com/api/devices/<device-id>/sidecar \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Download Device Sidecar Config
|
||||
|
||||
Generates the same configuration as a downloadable `.bookhoard.json` file for manual device setup.
|
||||
|
||||
**Endpoint**: `GET /api/devices/{id}/sidecar/download`
|
||||
**Auth**: User JWT (device owner or admin)
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
**Headers**:
|
||||
|
||||
- `Content-Type`: `application/json`
|
||||
- `Content-Disposition`: attachment; filename="<device-name>.bookhoard.json"
|
||||
|
||||
**Body**: the sidecar JSON (same shape as above).
|
||||
@@ -28,14 +28,19 @@ Register a new device for sync.
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "uuid",
|
||||
"registration_id": "registration-uuid",
|
||||
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
|
||||
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||
"expires_in": 300
|
||||
"expires_in": 300,
|
||||
"poll_interval": 3,
|
||||
"setup_instructions": {
|
||||
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes. Poll `POST /api/devices/register/status` at `poll_interval` seconds until `status` is `approved`, at which point the response includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Analytics GetTests
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||
|
||||
Kobo analytics endpoint (device compatibility).
|
||||
|
||||
**Endpoint**: `POST /api/sync/kobo/v1/analytics/gettests`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Bookmark Sync
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||
|
||||
Sync bookmarks from Kobo device.
|
||||
|
||||
**Endpoint**: `POST /api/sync/kobo/bookmark`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Kobo Initialization
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||
|
||||
Initialize Kobo device sync.
|
||||
|
||||
**Endpoint**: `GET /api/sync/kobo/v1/initialization`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Markup Sync
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||
|
||||
Sync markup highlights and annotations from Kobo device.
|
||||
|
||||
**Endpoint**: `POST /api/sync/kobo/markup`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Sync From Server
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||
|
||||
Push content and metadata to Kobo device.
|
||||
|
||||
**Endpoint**: `POST /api/sync/kobo/sync-from-server`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Resolve Book
|
||||
|
||||
Map a book's file SHA-256 to its Bookhoard UUID without touching progress
|
||||
state. Used by devices to link a freshly downloaded book before their first
|
||||
pull, so the device's first-page position is never pushed (which would
|
||||
conflict with server-side progress for books already mid-read).
|
||||
|
||||
**Endpoint**: `GET /api/sync/koreader/resolve`
|
||||
**Auth**: Required (Device authentication)
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ------------------------------------ |
|
||||
| sha256 | string | Yes | File content hash (64 hex characters) |
|
||||
|
||||
Resolution is format-aware: the hash is checked against both
|
||||
`media_items.file_sha256` and `media_item_formats.file_sha256`, so a
|
||||
converted file (KEPUB/PDF) matches its media item too.
|
||||
|
||||
## Device Authentication
|
||||
|
||||
This endpoint requires device authentication (not user JWT). Devices
|
||||
authenticate using their device credentials.
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/sync/koreader/resolve?sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
Authorization: Bearer {device_token}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"book_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | -------------------------------------------- |
|
||||
| 400 | Missing or malformed `sha256` parameter |
|
||||
| 401 | Device authentication failed |
|
||||
| 404 | No book in the library matches the given hash |
|
||||
@@ -1,49 +1,81 @@
|
||||
# Sync Bookmarks
|
||||
|
||||
Sync bookmarks from KOReader device.
|
||||
Sync bookmarks, notes, and highlights from a KOReader device (bidirectional — the response also returns the server's current state for the book so the device can reconcile).
|
||||
|
||||
**Endpoint**: `POST /api/sync/koreader/bookmarks`
|
||||
**Auth**: Required (Device authentication)
|
||||
|
||||
## Device Authentication
|
||||
|
||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
||||
**Auth**: Device token (Bearer)
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ------------------------- |
|
||||
| device_id | string (UUID) | Yes | Device UUID |
|
||||
| bookmarks | array | Yes | Array of bookmark objects |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | --------------------- | ----------------------------------------------------------------- |
|
||||
| book_uuid | string | one of uuid/sha | Book UUID (highest-confidence match) |
|
||||
| book_sha256 | string | one of uuid/sha | Full-file SHA-256 (64 hex chars); format-aware (also matches `media_item_formats`, so a KEPUB/PDF download matches) |
|
||||
| bookmarks | array | No | Bookmark objects |
|
||||
| notes | array | No | Note objects |
|
||||
| highlights | array | No | Highlight objects |
|
||||
|
||||
### Bookmark Object
|
||||
At least one of `book_uuid` or `book_sha256` is required; `book_sha256` resolves through the shared BookResolver.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | ------- | -------- | -------------------------- |
|
||||
| book | string | Yes | Book identifier |
|
||||
| chapter | string | No | Chapter title |
|
||||
| page | integer | No | Page number |
|
||||
| position | float | Yes | Position in document (0-1) |
|
||||
| notes | string | No | Bookmark notes |
|
||||
| highlighted_text | string | No | Highlighted text |
|
||||
| time | string | Yes | ISO 8601 timestamp |
|
||||
| created_at | string | Yes | ISO 8601 timestamp |
|
||||
### Bookmark / Note / Highlight Object
|
||||
|
||||
All three types share the same KOReader annotation shape:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------- | -------- | ---------------------------------------------------- |
|
||||
| chapter | int | No | Chapter index |
|
||||
| datetime | string | No | ISO 8601 creation/edit timestamp |
|
||||
| pos0 / pos1 | string | No | Start/end xpointer (or `page:N` / bare page) |
|
||||
| page | int | No | Page number (fallback location when `pos0` is empty) |
|
||||
| text | string | No | Highlighted text |
|
||||
| notes | string | No | Note text attached to the annotation |
|
||||
| type | string | No | Annotation type (`highlight`, `note`, `bookmark`) |
|
||||
| color | string | No | Highlight color (highlights only) — KOReader palette name, see below |
|
||||
| percentage | float | No | Position within the book (0-1) |
|
||||
| book_sha256 | string | No | Per-annotation SHA-256; overrides the request-level book match |
|
||||
| dedup_key | string | No | Stable echo key; an entry whose content is unchanged from what the server previously served is recognized as an echo rather than a new edit |
|
||||
|
||||
### Color Semantics
|
||||
|
||||
KOReader paints highlights from a fixed palette of color names; the web reader uses hex swatches. Colors are mapped at the boundary (unmappable values fall back to yellow on both sides):
|
||||
|
||||
| KOReader name | Web hex |
|
||||
| ------------- | --------- |
|
||||
| yellow, orange | `#ffd54f` |
|
||||
| green, olive | `#a5d6a7` |
|
||||
| cyan, blue | `#90caf9` |
|
||||
| purple | `#ce93d8` |
|
||||
| red | `#f48fb1` |
|
||||
|
||||
- An echo (device re-reporting an annotation it received from the server) carries **no color**, so the stored web color is never clobbered.
|
||||
- A non-empty color means the user edited the highlight on the device; it is mapped to the nearest web swatch.
|
||||
|
||||
### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"book_sha256": "64-hex-char-sha256",
|
||||
"bookmarks": [
|
||||
{
|
||||
"book": "book.epub",
|
||||
"chapter": "Chapter 1",
|
||||
"chapter": 3,
|
||||
"datetime": "2026-08-20T10:00:00Z",
|
||||
"pos0": "/body/Doc[4]/Sec[2]",
|
||||
"page": 25,
|
||||
"position": 0.125,
|
||||
"notes": "Important section",
|
||||
"highlighted_text": "Text to remember",
|
||||
"time": "2026-02-08T10:00:00Z",
|
||||
"created_at": "2026-02-08T10:00:00Z"
|
||||
"text": "",
|
||||
"type": "bookmark",
|
||||
"percentage": 0.125
|
||||
}
|
||||
],
|
||||
"highlights": [
|
||||
{
|
||||
"datetime": "2026-08-20T10:05:00Z",
|
||||
"pos0": "/body/Doc[4]/Sec[2]/text()[3]:0",
|
||||
"pos1": "/body/Doc[4]/Sec[2]/text()[3]:42",
|
||||
"text": "Text to remember",
|
||||
"notes": "Why this matters",
|
||||
"type": "highlight",
|
||||
"color": "blue",
|
||||
"dedup_key": "echo-key-from-server"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,15 +85,17 @@ This endpoint requires device authentication (not user JWT). Devices authenticat
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Bookmarks synced successfully",
|
||||
"synced_count": 1
|
||||
"sync_status": "ok",
|
||||
"bookmarks_synced": 1,
|
||||
"notes_synced": 0,
|
||||
"highlights_synced": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------- |
|
||||
| 401 | Device authentication failed |
|
||||
| 400 | Invalid request data |
|
||||
| 404 | Device not found |
|
||||
| Code | Description |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 400 | Invalid request, or neither uuid nor SHA provided |
|
||||
| 401 | Missing/invalid device token |
|
||||
| 404 | Book not found by SHA-256 |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Deleted Annotations History
|
||||
|
||||
List, restore, or permanently delete tombstoned annotations (highlights,
|
||||
notes, bookmarks) for a book. Deletions — from the web or propagated from a
|
||||
synced device — are soft-deleted and retained for the sync retention window
|
||||
(default 30 days), powering the book page's "Recently deleted" list. A
|
||||
restore returns the row to the active set on every synced device; a purge
|
||||
removes it immediately and irreversibly.
|
||||
|
||||
All endpoints require user JWT authentication and operate only on the
|
||||
caller's own annotations.
|
||||
|
||||
## List Deleted Annotations
|
||||
|
||||
**Endpoint**: `GET /api/media-items/:id/annotations/deleted`
|
||||
|
||||
Returns tombstoned annotations for the book, newest deletion first.
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted_annotations": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"annotation_type": "highlight",
|
||||
"display_text": "the chosen text",
|
||||
"secondary_text": "user note",
|
||||
"color": "#ffd54f",
|
||||
"deleted_at": "2026-08-22T15:04:05Z",
|
||||
"created_at": "2026-08-01T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| annotation_type | `highlight`, `note`, or `bookmark` |
|
||||
| display_text | Highlighted text / note content / bookmark title |
|
||||
| secondary_text | Note text (highlights) or notes field (bookmarks) |
|
||||
|
||||
## Restore Deleted Annotation
|
||||
|
||||
**Endpoint**: `POST /api/media-items/:id/annotations/:annotationId/restore`
|
||||
|
||||
Body (or query param) `annotation_type` must be `highlight`, `note`, or
|
||||
`bookmark`. Clears the tombstone; the annotation reappears in the active
|
||||
set and re-syncs to devices on their next pull.
|
||||
|
||||
```json
|
||||
{ "annotation_type": "highlight" }
|
||||
```
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
{ "restored": true }
|
||||
```
|
||||
|
||||
404 when no matching *deleted* annotation exists for this user and book.
|
||||
|
||||
## Permanently Delete Annotation
|
||||
|
||||
**Endpoint**: `DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark`
|
||||
|
||||
Removes the tombstoned row from the history immediately. Irreversible —
|
||||
unlike the tombstone itself, which is restorable until the retention window
|
||||
lapses and the daily maintenance sweep purges it.
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
{ "purged": true }
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 400 | Invalid IDs or missing/unknown `annotation_type` |
|
||||
| 401 | Not authenticated |
|
||||
| 404 | No matching deleted annotation |
|
||||
@@ -1,5 +1,7 @@
|
||||
# Kobo Sync Protocol
|
||||
|
||||
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change. Until then, KOReader (which runs on Kobo hardware) is fully supported.
|
||||
|
||||
Kobo uses a proprietary sync protocol with JSON payloads.
|
||||
|
||||
## Kobo Markup Sync
|
||||
|
||||
@@ -32,7 +32,9 @@ KOReader uses a custom JSON-based sync protocol.
|
||||
| books[].chapter | integer | No | Current chapter |
|
||||
| books[].epubcfi | string | No | EPUB CFI location |
|
||||
| books[].character | integer | No | Character offset |
|
||||
| books[].bookmarks | array | No | Array of bookmarks/highlights |
|
||||
| books[].bookmarks | array | No | Array of bookmarks/highlights (shape, color mapping, and echo/dedup rules: see [Sync Bookmarks](../koreader/sync_bookmarks.md)) |
|
||||
| books[].deleted_highlights | array | No | Highlights deleted on the device: `[{ "dedup_key": "..." }]` — keys previously served to this device (see [Deletion propagation](#deletion-propagation)) |
|
||||
| books[].deleted_bookmarks | array | No | Bookmarks deleted on the device: `[{ "dedup_key": "..." }]` |
|
||||
|
||||
\* At least one of `uuid` or `sha256` should be present. The server resolves the
|
||||
book through the shared `BookResolver` with this priority: `uuid` → `sha256` →
|
||||
@@ -94,6 +96,41 @@ hash differs from the primary format's hash.
|
||||
}
|
||||
```
|
||||
|
||||
## Book Resolution (UUID lookup)
|
||||
|
||||
**Endpoint**: `GET /api/sync/koreader/resolve?sha256={hash}`
|
||||
**Auth**: Device token required
|
||||
|
||||
Read-only lookup mapping a file SHA-256 to the book's UUID (format-aware,
|
||||
same `BookResolver` path as the progress push). Devices call this on the
|
||||
first open of a newly downloaded book to learn the UUID **before** their
|
||||
first pull. Full details: [Resolve Book](../koreader/resolve_book.md).
|
||||
|
||||
This matters for conflict avoidance: a device that pushes to bootstrap its
|
||||
identity transmits its current (first-page) position, which the server
|
||||
treats as a real progress update — overwriting/conflicting with genuine
|
||||
mid-read progress from other sources. Resolve, then pull, then push.
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/sync/koreader/resolve?sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
Authorization: Bearer device-token
|
||||
```
|
||||
|
||||
### Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"book_uuid": "book-uuid",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name"
|
||||
}
|
||||
```
|
||||
|
||||
404 when no book in the library matches the hash.
|
||||
|
||||
## KOReader Metadata Fetch
|
||||
|
||||
**Endpoint**: `GET /api/sync/koreader/metadata/{book_uuid}`
|
||||
@@ -135,6 +172,32 @@ returned so clients can cache it regardless of how the book was originally
|
||||
obtained. The library list endpoint (`GET /api/sync/koreader/library`) includes
|
||||
the same `sha256` field on each book.
|
||||
|
||||
## Deletion propagation
|
||||
|
||||
The progress push is upsert-only: absence of an annotation from
|
||||
`highlights`/`notes`/`bookmarks` is **never** interpreted as a delete (a
|
||||
client with a category disabled must not wipe the server). Deletions are
|
||||
reported explicitly:
|
||||
|
||||
- Devices remember the `dedup_key` of every annotation the server served
|
||||
them (persisted locally, e.g. KOReader's sidecar `bookhoard_known_keys`).
|
||||
- When one of those annotations no longer exists locally, the next push
|
||||
lists its key in `deleted_highlights` / `deleted_bookmarks`.
|
||||
- The server tombstones the matching rows (`deleted = TRUE`, kept for the
|
||||
retention window). Tombstones are served back to *other* devices via the
|
||||
metadata fetch's `deleted_highlights` / `deleted_bookmarks` arrays so the
|
||||
deletion converges everywhere.
|
||||
- A stale replay pushing the annotation's content cannot resurrect the
|
||||
tombstone: device pushes carry no modification timestamp, so the save is
|
||||
treated as older than the delete.
|
||||
- Restoring is possible from the web book page's deleted-annotation
|
||||
history (`GET /api/media-items/:id/annotations/deleted`, restore/purge
|
||||
endpoints) until the retention window lapses.
|
||||
|
||||
Because keys are only learned from server pulls, a device-native annotation
|
||||
deleted locally is simply never pushed again — it can never be mis-flagged
|
||||
as a server annotation deletion.
|
||||
|
||||
## Book identification
|
||||
|
||||
Every client/sync interface (KOReader, Kobo, OPDS, the device-link UI, and any
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# System Config API
|
||||
|
||||
## Overview
|
||||
|
||||
Raw key/value system configuration storage (backed by the `system_config` table). Unlike the typed [System Settings API](settings.md), this endpoint reads and writes arbitrary config keys as plain strings — including keys without registry metadata, such as `base_url`.
|
||||
|
||||
**Base URL**: `/api/system`
|
||||
**Authentication**: Admin JWT token required
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Get System Configuration
|
||||
|
||||
Retrieve all system configuration entries as a flat key/value map.
|
||||
|
||||
**Endpoint**: `GET /api/system/config`
|
||||
|
||||
**Authentication**: Admin role required
|
||||
|
||||
**Response**: **200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"base_url": "http://192.168.1.100:8765",
|
||||
"default_timezone": "America/New_York"
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
curl -X GET https://bookhoard.example.com/api/system/config \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update System Configuration
|
||||
|
||||
Update one or more config values.
|
||||
|
||||
**Endpoint**: `PUT /api/system/config`
|
||||
|
||||
**Authentication**: Admin role required
|
||||
|
||||
**Request Body**: a flat map of keys to string values. Only the supplied keys are updated.
|
||||
|
||||
```json
|
||||
{
|
||||
"base_url": "https://bookhoard.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation**: values for known keys are validated where applicable — for example, `default_timezone` must be a valid IANA timezone (`time.LoadLocation`); invalid values return `400` without persisting.
|
||||
|
||||
**Response**: **200 OK** on success; `400` (invalid value/format), `401`, `403`, `500` on failure.
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
curl -X PUT https://bookhoard.example.com/api/system/config \
|
||||
-H "Authorization: Bearer <admin_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"base_url": "https://bookhoard.example.com"}'
|
||||
```
|
||||
|
||||
> **Note:** settings that appear in the typed settings registry (e.g. `default_timezone`) are better managed through [`PUT /api/system/settings`](settings.md), which also returns metadata and reload hints. Writes through either endpoint refresh the shared registry cache.
|
||||
|
||||
---
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
- [System Settings API](settings.md) — typed, validated tunable settings with metadata
|
||||
- `GET /api/devices/:id/sidecar` — device setup config derived from system config (see [Devices API](../devices/))
|
||||
@@ -1,10 +1,10 @@
|
||||
# System Scan Settings API
|
||||
# System Settings API
|
||||
|
||||
## Overview
|
||||
|
||||
The System Scan Settings API allows administrators to configure system-wide scan settings that apply to all libraries. These settings control the automatic scanning behavior for the entire Bookhoard system.
|
||||
The System Settings API is the canonical way to read and write Bookhoard's tunable system settings (scanning, security, rate limits, sync, performance, and defaults). Every setting carries full metadata — type, range, category, description, and whether a restart is required — so the admin UI (and API clients) can render and validate settings generically.
|
||||
|
||||
**Base URL**: `/api/libraries`
|
||||
**Base URL**: `/api/system`
|
||||
**Authentication**: Admin JWT token required
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
@@ -12,49 +12,61 @@ The System Scan Settings API allows administrators to configure system-wide scan
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Get System Scan Settings
|
||||
### List All Settings
|
||||
|
||||
Retrieve the current system-wide scan settings.
|
||||
Retrieve every known tunable setting with its current value and metadata.
|
||||
|
||||
**Endpoint**: `GET /api/libraries/scan-settings`
|
||||
**Endpoint**: `GET /api/system/settings`
|
||||
|
||||
**Authentication**: Admin role required
|
||||
|
||||
**Response**:
|
||||
|
||||
- **200 OK**: Returns current scan settings
|
||||
- **401 Unauthorized**: Invalid or missing authentication
|
||||
- **403 Forbidden**: User does not have admin role
|
||||
- **500 Internal Server Error**: Server error
|
||||
|
||||
**Response Body**:
|
||||
**Response**: **200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_poll_interval_seconds": 60,
|
||||
"auto_scan_enabled": true
|
||||
}
|
||||
[
|
||||
{
|
||||
"key": "scan_poll_interval_seconds",
|
||||
"value": "60",
|
||||
"type": "int",
|
||||
"min": "1",
|
||||
"max": "3600",
|
||||
"requires_restart": false,
|
||||
"category": "scanner",
|
||||
"group": "Scanning",
|
||||
"description": "How often to scan all libraries (seconds)",
|
||||
"is_default": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
**Entry fields**:
|
||||
|
||||
- `scan_poll_interval_seconds` (integer): How often to poll for file changes in seconds (1-3600)
|
||||
- `auto_scan_enabled` (boolean): Whether auto-scanning is enabled system-wide
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------- | -------------------------------------------------------- |
|
||||
| `key` | string | Setting identifier (stable API name) |
|
||||
| `value` | string | Current value (validated/clamped by the registry) |
|
||||
| `type` | string | `int`, `bool`, or `string` |
|
||||
| `min` / `max` | string | Range bounds for `int` settings (omitted otherwise) |
|
||||
| `requires_restart` | boolean | Change takes effect only after a server restart |
|
||||
| `category` | string | Coarse area: `scanner`, `security`, `api`, `sync`, `performance`, `general` |
|
||||
| `group` | string | Sub-section shown in the admin UI |
|
||||
| `description` | string | Human-readable description |
|
||||
| `is_default` | boolean | True when the current value equals the compiled default |
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
curl -X GET https://bookhoard.example.com/api/libraries/scan-settings \
|
||||
curl -X GET https://bookhoard.example.com/api/system/settings \
|
||||
-H "Authorization: Bearer <admin_token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update System Scan Settings
|
||||
### Update a Setting
|
||||
|
||||
Update the system-wide scan settings.
|
||||
Validate, persist, and reload a single setting.
|
||||
|
||||
**Endpoint**: `PUT /api/libraries/scan-settings`
|
||||
**Endpoint**: `PUT /api/system/settings`
|
||||
|
||||
**Authentication**: Admin role required
|
||||
|
||||
@@ -62,128 +74,123 @@ Update the system-wide scan settings.
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_poll_interval_seconds": 30,
|
||||
"auto_scan_enabled": true
|
||||
"key": "scan_poll_interval_seconds",
|
||||
"value": "30"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
| Field | Type | Required | Description |
|
||||
| ------- | ------ | -------- | ------------------------------- |
|
||||
| `key` | string | Yes | Setting key (from the list) |
|
||||
| `value` | string | Yes | New value, as a string |
|
||||
|
||||
- `scan_poll_interval_seconds` (integer, required): How often to poll for file changes in seconds
|
||||
- Minimum: 1 (1 second)
|
||||
- Maximum: 3600 (1 hour)
|
||||
- Default: 60
|
||||
- `auto_scan_enabled` (boolean, required): Whether auto-scanning is enabled system-wide
|
||||
- Default: true
|
||||
|
||||
**Response**:
|
||||
|
||||
- **200 OK**: Settings updated successfully
|
||||
- **400 Bad Request**: Invalid request parameters
|
||||
- **401 Unauthorized**: Invalid or missing authentication
|
||||
- **403 Forbidden**: User does not have admin role
|
||||
- **500 Internal Server Error**: Server error
|
||||
|
||||
**Success Response Body**:
|
||||
**Response**: **200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_poll_interval_seconds": 30,
|
||||
"auto_scan_enabled": true,
|
||||
"message": "scan settings updated successfully"
|
||||
"key": "scan_poll_interval_seconds",
|
||||
"value": "30",
|
||||
"type": "int",
|
||||
"min": "1",
|
||||
"max": "3600",
|
||||
"requires_restart": false,
|
||||
"category": "scanner",
|
||||
"group": "Scanning",
|
||||
"description": "How often to scan all libraries (seconds)",
|
||||
"is_default": false,
|
||||
"reload_required": false,
|
||||
"message": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response Body**:
|
||||
- `reload_required: true` means the change takes effect only after a restart (e.g. rate limits, worker pool, lockout settings).
|
||||
- Validation is type-aware: `int` values are checked against `min`/`max`, `bool` values must parse, `default_timezone` must be a valid IANA timezone via `time.LoadLocation`, and strings must be non-empty.
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "error message"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation Rules**:
|
||||
|
||||
- `scan_poll_interval_seconds` must be between 1 and 3600 seconds (1 second to 1 hour)
|
||||
- Both fields are required
|
||||
**Errors**: `400` (unknown key, invalid value, out of range), `401`, `403`, `503` (settings registry not initialized).
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
curl -X PUT https://bookhoard.example.com/api/libraries/scan-settings \
|
||||
curl -X PUT https://bookhoard.example.com/api/system/settings \
|
||||
-H "Authorization: Bearer <admin_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"scan_poll_interval_seconds": 30,
|
||||
"auto_scan_enabled": true
|
||||
}'
|
||||
-d '{"key": "scan_poll_interval_seconds", "value": "30"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
## Setting Catalog
|
||||
|
||||
### Poll Interval
|
||||
Current tunable settings by category:
|
||||
|
||||
The `scan_poll_interval_seconds` setting determines how often the system will poll library folders for file changes as a fallback to real-time file watching.
|
||||
**Scanner** (`scanner`)
|
||||
|
||||
**Constraints**:
|
||||
| Key | Default | Range | Restart | Description |
|
||||
| ------------------------------ | ------- | -------- | ------- | ----------------------------------------- |
|
||||
| `scan_poll_interval_seconds` | `60` | 1-3600 | No | How often to scan all libraries (seconds) |
|
||||
| `auto_scan_enabled` | `true` | - | No | Whether auto-scanning is enabled |
|
||||
|
||||
- Minimum: 1 second
|
||||
- Maximum: 3600 seconds (1 hour)
|
||||
- Default: 60 seconds
|
||||
**General** (`general`)
|
||||
|
||||
### Auto-Scan Toggle
|
||||
| Key | Default | Restart | Description |
|
||||
| ----------------- | ------- | ------- | ------------------------- |
|
||||
| `default_timezone`| `UTC` | No | System default timezone |
|
||||
|
||||
The `auto_scan_enabled` setting acts as a master switch for automatic scanning:
|
||||
**Security** (`security`)
|
||||
|
||||
- When `true`: File watching and polling fallback are active for all libraries
|
||||
- When `false`: No automatic file monitoring occurs (manual scans still available)
|
||||
| Key | Default | Range | Restart | Description |
|
||||
| ---------------------------- | --------- | ------------ | ------- | ---------------------------------------------- |
|
||||
| `session_duration_seconds` | `604800` | 300-31536000 | No | How long a login session stays valid |
|
||||
| `password_min_length` | `8` | 1-128 | No | Minimum password length |
|
||||
| `password_require_upper` | `true` | - | No | Require at least one uppercase letter |
|
||||
| `password_require_lower` | `true` | - | No | Require at least one lowercase letter |
|
||||
| `password_require_number` | `true` | - | No | Require at least one number |
|
||||
| `password_require_special` | `true` | - | No | Require at least one special character |
|
||||
| `auth_rate_limit_per_min` | `10` | 1-10000 | **Yes** | Global auth API rate limit (req/min) |
|
||||
| `login_max_attempts` | `5` | 1-100 | **Yes** | Failed login attempts before lockout |
|
||||
| `login_lockout_minutes` | `15` | 1-10080 | **Yes** | Lockout duration after failed logins |
|
||||
|
||||
### File Watching System
|
||||
**API** (`api`)
|
||||
|
||||
The scan settings control the file watching system which consists of:
|
||||
| Key | Default | Range | Restart | Description |
|
||||
| ------------------------------- | ------- | --------- | ------- | ------------------------------------ |
|
||||
| `opds_default_page_size` | `50` | 1-500 | No | Default OPDS page size |
|
||||
| `opds_max_page_size` | `200` | 1-1000 | No | Maximum OPDS page size |
|
||||
| `device_rate_sync_per_min` | `60` | 1-10000 | No | Device sync requests per minute |
|
||||
| `device_rate_progress_per_min` | `120` | 1-10000 | No | Device progress requests per minute |
|
||||
| `device_rate_metadata_per_min` | `30` | 1-10000 | No | Device metadata requests per minute |
|
||||
|
||||
1. **Real-time file watching**: Uses fsnotify to detect file changes immediately
|
||||
2. **Polling fallback**: If file watching fails or is unavailable, polls folders at the configured interval
|
||||
**Sync** (`sync`)
|
||||
|
||||
The system applies these settings to all configured libraries automatically on startup.
|
||||
| Key | Default | Range | Restart | Description |
|
||||
| ------------------------------- | ------- | -------- | ------- | -------------------------------------------------- |
|
||||
| `annotation_tombstone_ttl_days` | `30` | 1-3650 | No | How long deleted annotations are kept before purge |
|
||||
| `sync_queue_interval_seconds` | `5` | 1-3600 | **Yes** | How often the sync queue flushes |
|
||||
| `sync_queue_batch_size` | `50` | 1-10000 | **Yes** | Max items processed per sync queue flush |
|
||||
|
||||
**Performance** (`performance`)
|
||||
|
||||
| Key | Default | Range | Restart | Description |
|
||||
| ------------------------ | ------- | --------- | ------- | ------------------------------------------- |
|
||||
| `conversion_cache_ttl_hours` | `24` | 1-720 | No | How long converted (KEPUB) files are cached |
|
||||
| `worker_pool_size` | `3` | 1-100 | **Yes** | Number of background worker goroutines |
|
||||
| `worker_queue_cap` | `100` | 1-10000 | **Yes** | Background worker job queue capacity |
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
## Legacy Scan Settings Routes
|
||||
|
||||
| Status Code | Error Description |
|
||||
| ----------- | ---------------------------------------------------------- |
|
||||
| 400 | Invalid request parameters (e.g., frequency outside range) |
|
||||
| 401 | Missing or invalid JWT token |
|
||||
| 403 | User lacks admin role |
|
||||
| 500 | Internal server error (e.g., database connection issue) |
|
||||
The older JSON routes still work for backward compatibility and now refresh the settings registry cache on write, but they are **superseded** by `GET/PUT /api/system/settings`:
|
||||
|
||||
- `GET /api/libraries/scan-settings` — returns only `scan_poll_interval_seconds` and `auto_scan_enabled`
|
||||
- `PUT /api/libraries/scan-settings` — accepts `{ "scan_poll_interval_seconds": int, "auto_scan_enabled": bool }`
|
||||
|
||||
Both fields are backed by the same registry entries documented above.
|
||||
|
||||
---
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
- `POST /api/libraries/{id}/scan` - Manually trigger a scan for a specific library (admin only)
|
||||
- `GET /api/libraries` - List all libraries
|
||||
- `GET /api/libraries/{id}` - Get details for a specific library
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
This API has been updated to use a new polling-based scanning system. The following changes were made:
|
||||
|
||||
- **Changed**: `scan_frequency_minutes` renamed to `scan_poll_interval_seconds`
|
||||
- **Changed**: Unit changed from minutes to seconds (15-1440 minutes → 1-3600 seconds)
|
||||
- **Removed**: Old scheduler-based scanning system
|
||||
- **Added**: Real-time file watching with polling fallback
|
||||
- **Preserved**: API endpoint paths remain the same
|
||||
|
||||
The new system ensures that:
|
||||
|
||||
1. File changes are detected in real-time when possible (via fsnotify)
|
||||
2. Polling fallback catches missed events at the configured interval
|
||||
3. Settings apply to all libraries system-wide
|
||||
4. Only administrators can modify scan settings
|
||||
5. The `auto_scan_enabled` setting controls both file watching and polling
|
||||
- `GET/PUT /api/system/config` — raw key/value system configuration (see [System Config API](config.md))
|
||||
- `POST /api/scanner/scan` — trigger a manual scan (see [Scanner API](../scanner/))
|
||||
- `GET /api/admin/hash-conflicts` — duplicates found during hashing (see [Hash Conflicts API](../admin/hash-conflicts.md))
|
||||
|
||||
+14
-14
@@ -11,8 +11,8 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
**[User Documentation Portal](user/user-guide.md)** - Guides for using Bookhoard features
|
||||
|
||||
- **Device Setup**
|
||||
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Complete Kobo e-reader configuration
|
||||
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook
|
||||
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
|
||||
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
|
||||
|
||||
- **Sync Configuration**
|
||||
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
||||
@@ -38,12 +38,12 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
- [Queue API](developer/api/queue/) - Sync queue management endpoints
|
||||
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
||||
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
|
||||
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints
|
||||
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
|
||||
- [WebSocket API](developer/api/websocket/) - Real-time sync events
|
||||
|
||||
- **Protocol Specifications**
|
||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
|
||||
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
|
||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon)
|
||||
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
||||
|
||||
### 🔧 For Operations
|
||||
@@ -62,8 +62,8 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
|
||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||
|
||||
- [Development Guide](contributing/Development.md) - Architecture, setup, testing
|
||||
- [PROJECT_GUIDELINES.md](PROJECT_GUIDELINES.md) - Development rules and standards
|
||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
||||
- [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) - Development rules and standards
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +75,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
||||
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
|
||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/Development.md) |
|
||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||
|
||||
---
|
||||
@@ -87,13 +87,13 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
| Question | Answer |
|
||||
| --------------------------- | ------------------------------------------------------ |
|
||||
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
|
||||
| ...set up my Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) |
|
||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
||||
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
||||
| ...contribute code? | [Development Guide](contributing/Development.md) |
|
||||
| ...contribute code? | [Development Guide](contributing/development.md) |
|
||||
|
||||
### "Where is..."
|
||||
|
||||
@@ -112,7 +112,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
|
||||
### Set up a new device
|
||||
|
||||
1. Choose your device: [Kobo](user/devices/kobo-setup.md) or [KOReader](user/devices/koreader-setup.md)
|
||||
1. Choose your device: [KOReader](user/devices/koreader-setup.md) (works on Kindle, Kobo, and PocketBook hardware)
|
||||
2. Understand sync: [Sync Guide](user/sync-guide.md)
|
||||
3. Troubleshoot: Device-specific guides
|
||||
|
||||
@@ -128,7 +128,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
1. Follow [README.md](../README.md) quick start
|
||||
2. Configure environment: [.env.example](../.env.example)
|
||||
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
|
||||
4. Check [Development Guide](contributing/Development.md) for performance tuning
|
||||
4. Check [Development Guide](contributing/development.md) for performance tuning
|
||||
|
||||
---
|
||||
|
||||
@@ -138,12 +138,12 @@ When adding new features:
|
||||
|
||||
1. **User-facing features** → Update relevant User docs
|
||||
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
|
||||
3. **Backend changes** → Update [Development Guide](contributing/Development.md)
|
||||
3. **Backend changes** → Update [Development Guide](contributing/development.md)
|
||||
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
||||
|
||||
Keep [PROJECT_GUIDELINES.md](PROJECT_GUIDELINES.md) in mind for documentation standards.
|
||||
Keep [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) in mind for documentation standards.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-02-08
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
|
||||
@@ -8,12 +8,12 @@ When creating or managing a library, you can add folders containing your media f
|
||||
|
||||
The admin library page includes a folder browser to help you select folders on the server:
|
||||
|
||||
1. Navigate to **Admin → Library Management**
|
||||
2. Find the library you want to manage
|
||||
3. Click the **Folders** button
|
||||
4. Click **Browse** next to "Add folder path"
|
||||
1. Open the **Administration** panel in the sidebar (admins only) and go to **Libraries**
|
||||
2. Click a library in the list to expand its panel
|
||||
3. Find the **Folders** section
|
||||
4. Click **Browse** next to the folder path input — this opens the **Browse Folders** dialog
|
||||
5. Navigate through the server's filesystem
|
||||
6. Select a folder by clicking **Select This Folder**
|
||||
6. Select a folder; it fills the path input, then click **Add**
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -58,20 +58,22 @@ Calibre Library/
|
||||
|
||||
### Step 2: Add Library in Bookhoard
|
||||
|
||||
1. Navigate to **Admin** → **Libraries**
|
||||
2. Click **Add Library**
|
||||
1. Open the **Administration** panel in the sidebar and go to **Libraries**
|
||||
2. Click **Create Library**
|
||||
3. Configure:
|
||||
- **Name**: "My Calibre Library"
|
||||
- **Type**: Ebook (or Audiobook/Comic)
|
||||
- **Folder**: Path to your Calibre library
|
||||
- **Scan on save**: ✅ Checked
|
||||
4. Click **Save**
|
||||
- **Library Name**: "My Calibre Library"
|
||||
- **Description**: Optional
|
||||
- **Library Type**: Ebook (or Audiobook/Comic)
|
||||
4. Click the library in the list to expand its panel
|
||||
5. Add your Calibre library folder in the **Folders** section:
|
||||
- Enter the path (or click **Browse** to find it on the server) and click **Add**
|
||||
6. Trigger a scan (see below), or rely on watch mode if enabled
|
||||
|
||||
Bookhoard will automatically scan the library and import all books with their Calibre metadata.
|
||||
Bookhoard scans the library and imports all books with their Calibre metadata. Scan progress shows in the sidebar next to the logo.
|
||||
|
||||
### Step 3: Verify Import
|
||||
|
||||
1. Navigate to **Library** view
|
||||
1. Open the **Dashboard** or **All Books** page (sidebar navigation)
|
||||
2. Browse your imported books
|
||||
3. Check that:
|
||||
- Titles and authors are correct
|
||||
@@ -136,8 +138,8 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
**Scenario**: You have a Calibre library with 500 ebooks, all organized with series, tags, and custom covers.
|
||||
|
||||
**Steps**:
|
||||
1. Add the Calibre library folder in Bookhoard
|
||||
2. Enable "Scan on save"
|
||||
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
|
||||
2. Trigger a scan via the **Scanner API**, or let watch mode pick up the changed files (the File Watcher status is shown on the admin dashboard)
|
||||
3. Bookhoard imports all 500 books with:
|
||||
- Correct titles and authors
|
||||
- Series information (e.g., "Harry Potter #2")
|
||||
@@ -166,9 +168,8 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
**Steps**:
|
||||
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
||||
2. In Bookhoard, trigger a rescan:
|
||||
- Navigate to **Admin** → **Libraries**
|
||||
- Click **Rescan** on your library
|
||||
- Or use the **Scanner API** to force rescan
|
||||
- Via the **Scanner API** (`POST /api/scanner/scan`), or
|
||||
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
||||
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
||||
|
||||
**Result**: Bookhoard reflects your Calibre changes automatically.
|
||||
@@ -182,7 +183,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
**Solutions**:
|
||||
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
|
||||
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
|
||||
3. **Force rescan**: Use the "Force Rescan" option to re-import all metadata
|
||||
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically)
|
||||
4. **Check logs**: Review Bookhoard logs for parsing errors
|
||||
|
||||
### Incorrect Metadata
|
||||
@@ -219,7 +220,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
|
||||
**Do**:
|
||||
- ✅ Edit metadata in Calibre
|
||||
- ✅ Rescan in Bookhoard to sync changes
|
||||
- ✅ Let Bookhoard's next scan (or watch mode) pick up the changes
|
||||
- ✅ Use Calibre for library management
|
||||
|
||||
**Don't**:
|
||||
@@ -259,12 +260,11 @@ Stay tuned for updates!
|
||||
|
||||
### OPDS Integration
|
||||
|
||||
You can access your Bookhoard library (including Calibre-imported books) via OPDS from Calibre-aware devices:
|
||||
- Kobo e-readers
|
||||
- KOReader
|
||||
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
|
||||
- KOReader (Kindle, Kobo, PocketBook hardware)
|
||||
- Phone/tablet apps (KYBook, Chunky, etc.)
|
||||
|
||||
See the [Kobo Setup Guide](devices/kobo-setup.md) or [KOReader Setup Guide](devices/koreader-setup.md) for details.
|
||||
See the [KOReader Setup Guide](devices/koreader-setup.md) for details.
|
||||
|
||||
## FAQ
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Collections allow you to organize books across multiple libraries.
|
||||
|
||||
### From Collections Page
|
||||
|
||||
Navigate to `/collections` to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
|
||||
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
|
||||
|
||||
### From Dashboard
|
||||
|
||||
@@ -19,8 +19,24 @@ When viewing a specific library's dashboard, collections only show books from th
|
||||
|
||||
## Creating Collections
|
||||
|
||||
[Instructions for creating collections]
|
||||
1. Go to **Collections** (sidebar navigation)
|
||||
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
|
||||
3. Fill in the details:
|
||||
- **Name** - Collection name
|
||||
- **Description** - Optional description
|
||||
- **Icon** - Pick from the icon grid
|
||||
- **Color** - Pick a color swatch
|
||||
4. Click **Create Collection**
|
||||
|
||||
## Managing Collections
|
||||
|
||||
[Instructions for editing/deleting collections]
|
||||
Each collection in the list has icon buttons on its card:
|
||||
|
||||
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save.
|
||||
- **Delete** - Removes the collection after a confirmation prompt.
|
||||
|
||||
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections.
|
||||
|
||||
### Dashboard Sections
|
||||
|
||||
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)).
|
||||
|
||||
+14
-13
@@ -16,28 +16,27 @@ Smart sections are automatically generated based on your reading activity:
|
||||
|
||||
### User Collections
|
||||
|
||||
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
|
||||
Your collections appear as sections on the dashboard. To show or hide a collection's section:
|
||||
|
||||
To enable a collection:
|
||||
|
||||
1. Go to Collections
|
||||
2. Edit a collection
|
||||
3. Toggle "Show on Dashboard"
|
||||
4. Save
|
||||
1. Select the library in the **Library** bar
|
||||
2. Click the **Customize Dashboard** icon button
|
||||
3. Toggle the collection on or off
|
||||
4. Click "Save Changes"
|
||||
|
||||
### Customizing Your Dashboard
|
||||
|
||||
1. Click the ⚙️ (gear icon) in the top-right
|
||||
2. **Drag sections** to reorder them
|
||||
3. **Toggle visibility** with the switches
|
||||
4. **Adjust items per section** (10-50 items)
|
||||
5. Click "Save Changes"
|
||||
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
|
||||
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
|
||||
3. **Drag sections** to reorder them
|
||||
4. **Toggle visibility** with the switches
|
||||
5. **Adjust items per section** (10-50 items)
|
||||
6. Click "Save Changes"
|
||||
|
||||
Settings are saved per library.
|
||||
|
||||
### Library Switching
|
||||
|
||||
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
|
||||
Use the **Library** dropdown in the bar below the top bar to switch between libraries (including "All Libraries"). Each library has its own dashboard settings.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
@@ -45,6 +44,8 @@ Use the dropdown in the sticky header to switch between libraries. Each library
|
||||
- **Arrow Keys**: Scroll carousels horizontally
|
||||
- **Enter**: Open selected book
|
||||
|
||||
Hovering a carousel shows chevron buttons on either side for scrolling.
|
||||
|
||||
### Touch Gestures (Mobile)
|
||||
|
||||
- **Swipe**: Drag carousel left/right to scroll
|
||||
|
||||
+24
-632
@@ -1,650 +1,42 @@
|
||||
# Kobo Device Setup Guide
|
||||
|
||||
This guide will help you set up your Kobo e-reader to sync with Bookhoard for seamless cross-device reading progress synchronization.
|
||||
> ## 🚧 Coming Soon
|
||||
>
|
||||
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
|
||||
|
||||
## What is Kobo Sync?
|
||||
## Using a Kobo With Bookhoard Today
|
||||
|
||||
Bookhoard implements a Kobo-compatible sync protocol that allows your Kobo device to:
|
||||
You don't have to wait: **KOReader runs on Kobo hardware** and syncs fully with Bookhoard today — reading position, bookmarks, highlights, and notes, plus OPDS wireless book delivery.
|
||||
|
||||
- Sync reading progress across all your devices
|
||||
- Sync highlights and bookmarks
|
||||
- Sync reading statistics
|
||||
- Maintain device-specific metadata
|
||||
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
|
||||
|
||||
## Prerequisites
|
||||
## What's Planned for Native Kobo Sync
|
||||
|
||||
Before you begin, make sure you have:
|
||||
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard:
|
||||
|
||||
- ✅ A Kobo e-reader device (Clara, Aura, Nia, Libra, Sage, Elipsa, etc.)
|
||||
- ✅ A Bookhoard instance running and accessible on your network
|
||||
- ✅ Your Bookhoard credentials (username and password)
|
||||
- ✅ USB cable to connect your Kobo to your computer
|
||||
- ✅ Your Kobo connected to the same Wi-Fi network as your Bookhoard instance
|
||||
- **Reading position sync** — percentages, pages, and reading statistics
|
||||
- **Bookmarks, highlights, and notes** — synced with the web and other devices
|
||||
- **OPDS wireless delivery** — browse and download books directly on the Kobo
|
||||
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
|
||||
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
|
||||
|
||||
## Supported Kobo Devices
|
||||
|
||||
Bookhoard supports all Kobo devices that use the standard Kobo sync protocol:
|
||||
|
||||
- **Kobo Clara**: Clara 2E, Clara HD
|
||||
- **Kobo Aura**: Aura, Aura H2O, Aura ONE, Aura Edition 2
|
||||
- **Kobo Libra**: Libra 2, Libra H2O
|
||||
- **Kobo Forma**: All versions
|
||||
- **Kobo Sage**: All versions
|
||||
- **Kobo Elipsa**: All versions
|
||||
- **Kobo Nia**: All versions
|
||||
- **Kobo Touch**: Touch 2.0
|
||||
- **Kobo Glo**: Glo, Glo HD
|
||||
|
||||
## Device Registration
|
||||
|
||||
### Step 1: Find Your Kobo Serial Number
|
||||
|
||||
1. Turn on your Kobo device
|
||||
2. Go to **Settings** (gear icon)
|
||||
3. Select **Device Information**
|
||||
4. Note your **Device Serial Number** (e.g., N1234567890123)
|
||||
- This is your device identifier for registration
|
||||
|
||||
### Step 2: Register Your Device in Bookhoard
|
||||
|
||||
1. Log in to your Bookhoard web interface
|
||||
2. Navigate to **Device Management** → **Add New Device**
|
||||
3. Fill in the device details:
|
||||
- **Device Name**: A friendly name (e.g., "My Kobo Clara")
|
||||
- **Device Type**: Select "Kobo"
|
||||
- **Device Identifier**: Enter your Kobo serial number
|
||||
4. Click **Register Device**
|
||||
|
||||
You'll receive:
|
||||
|
||||
- An **Auth URL** to approve the device
|
||||
- Instructions for manual configuration
|
||||
|
||||
### Step 3: Approve Your Device
|
||||
|
||||
1. **Method A: QR Code**
|
||||
- If displayed, scan the QR code with your phone's camera
|
||||
- This will open the approval page in your browser
|
||||
- Log in and click **Approve**
|
||||
|
||||
2. **Method B: Manual URL**
|
||||
- Copy the Auth URL from the registration confirmation
|
||||
- Open it in your web browser
|
||||
- Log in to your Bookhoard account
|
||||
- Click **Approve Device**
|
||||
|
||||
Your device is now registered and ready for configuration!
|
||||
|
||||
## Configure Kobo Sync
|
||||
|
||||
### Step 1: Connect Kobo to Your Computer
|
||||
|
||||
1. Use your USB cable to connect Kobo to your computer
|
||||
2. Your computer should recognize Kobo as a storage device
|
||||
3. Kobo will show "Connected" and "Eject before disconnecting"
|
||||
|
||||
### Step 2: Edit Kobo Configuration File
|
||||
|
||||
#### Windows Users
|
||||
|
||||
1. Open **File Explorer** and navigate to your Kobo device
|
||||
2. Open the `.kobo` folder (hidden folder)
|
||||
3. Open `Kobo/Kobo eReader.conf` in a text editor (Notepad++, VS Code, etc.)
|
||||
|
||||
#### Mac Users
|
||||
|
||||
1. Kobo device appears on your Desktop
|
||||
2. Right-click the Kobo volume and select **Show Package Contents**
|
||||
3. Navigate to `.kobo/Kobo/Kobo eReader.conf`
|
||||
4. Open in a text editor (TextEdit, VS Code, etc.)
|
||||
|
||||
#### Linux Users
|
||||
|
||||
1. Kobo mounts at `/media/USERNAME/Kobo` or similar
|
||||
2. Navigate to `.kobo/Kobo/Kobo eReader.conf`
|
||||
3. Open in a text editor
|
||||
|
||||
### Step 3: Add Bookhoard Sync Configuration
|
||||
|
||||
After device registration is complete, you'll receive an API key and sync URL from Bookhoard.
|
||||
|
||||
Add the following section to the end of your `Kobo eReader.conf` file:
|
||||
|
||||
```ini
|
||||
[FeatureSettings]
|
||||
# Enable Kobo store replacement
|
||||
KoboStoreSyncDisabled=true
|
||||
|
||||
[Sync]
|
||||
# Bookhoard Sync Configuration (from Device Management page)
|
||||
ServerURL=http://YOUR_COMPUTER_IP:8765/api/sync/kobo/YOUR_API_KEY
|
||||
AutoSyncEnabled=true
|
||||
SyncFrequency=5
|
||||
```
|
||||
|
||||
**Where to find these values**:
|
||||
|
||||
- `YOUR_COMPUTER_IP`: Your Bookhoard server's IP address (e.g., 192.168.1.100)
|
||||
- `YOUR_API_KEY`: Copy from Bookhoard Device Management → Your Kobo Device → "Copy Sync URL"
|
||||
|
||||
**Example configuration**:
|
||||
|
||||
```ini
|
||||
[Sync]
|
||||
ServerURL=http://192.168.1.100:8765/api/sync/kobo/dev_abc123def456
|
||||
AutoSyncEnabled=true
|
||||
SyncFrequency=5
|
||||
```
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- The API key is generated during device registration
|
||||
- You can regenerate the API key anytime from Device Management if needed
|
||||
- Keep your API key confidential like a password
|
||||
- Bookhoard uses revocable API keys for security (not username/password)
|
||||
|
||||
**Replace the following with your actual values**:
|
||||
|
||||
- `YOUR_COMPUTER_IP`: Your computer's local IP address (e.g., 192.168.1.100)
|
||||
- `YOUR_BOOKHOARD_USERNAME`: Your Bookhoard email or username
|
||||
- `YOUR_BOOKHOARD_PASSWORD`: Your Bookhoard password
|
||||
|
||||
**Example configuration:**
|
||||
|
||||
```ini
|
||||
[Sync]
|
||||
ServerURL=http://192.168.1.100:8765/api/sync/kobo
|
||||
AutoSyncEnabled=true
|
||||
SyncFrequency=5
|
||||
Username=john@example.com
|
||||
Password=securePassword123
|
||||
```
|
||||
|
||||
### Step 4: Save and Eject
|
||||
|
||||
1. Save the `Kobo eReader.conf` file
|
||||
2. Safely eject your Kobo device from your computer
|
||||
3. Kobo will restart automatically
|
||||
|
||||
### Step 5: Verify Sync on Kobo
|
||||
|
||||
1. After Kobo restarts, go to **Settings** → **Sync & Backup**
|
||||
2. You should see "Bookhoard" listed as a sync provider
|
||||
3. Tap **Sync Now** to test the connection
|
||||
4. If successful, you'll see a "Sync Complete" message
|
||||
|
||||
## Sync Features
|
||||
|
||||
### Reading Progress Sync
|
||||
|
||||
Kobo syncs:
|
||||
|
||||
- **Percentage Read**: Overall book completion percentage
|
||||
- **Page Number**: Current page in fixed-layout books
|
||||
- **Time Spent**: Reading time statistics
|
||||
- **Last Read**: Timestamp of last reading session
|
||||
|
||||
### Annotations Sync
|
||||
|
||||
Kobo syncs:
|
||||
|
||||
- **Bookmarks**: Page positions saved for quick access
|
||||
- **Highlights**: Highlighted text passages
|
||||
- **Notes**: Notes attached to highlights
|
||||
- **Reading Statistics**: Pages read, time spent
|
||||
|
||||
### Shelf Management
|
||||
|
||||
Kobo syncs:
|
||||
|
||||
- **Book Collections**: Your organized shelves
|
||||
- **Shelf Contents**: Books in each collection
|
||||
- **Sync Metadata**: When shelves were last updated
|
||||
|
||||
## OPDS Wireless Book Delivery
|
||||
|
||||
### What is OPDS?
|
||||
|
||||
OPDS (Open Publication Distribution System) allows your Kobo to **wirelessly download books** from Bookhoard - no USB cable needed!
|
||||
|
||||
### OPDS Benefits
|
||||
|
||||
- **No USB Required**: Download books directly to your Kobo over Wi-Fi
|
||||
- **On-Demand Delivery**: Browse your Bookhoard library from your Kobo
|
||||
- **Collection Support**: Download books from specific collections
|
||||
- **Progress Tracking**: Books downloaded via OPDS sync progress automatically
|
||||
- **Format Conversion**: Automatic EPUB to KEPUB conversion for better Kobo support
|
||||
|
||||
### Enable OPDS on Your Kobo
|
||||
|
||||
#### Option 1: Automatic Configuration (Recommended)
|
||||
|
||||
1. After registering your Kobo device, a **Download Configuration** button appears
|
||||
2. Click **Download Configuration** to get a `.kobo` configuration file
|
||||
3. Copy this file to your Kobo's `.kobo/` directory via USB
|
||||
4. Eject and restart your Kobo
|
||||
5. OPDS catalog will automatically appear in your Kobo's store
|
||||
|
||||
#### Option 2: Manual Configuration
|
||||
|
||||
1. Connect your Kobo to your computer via USB
|
||||
2. Navigate to `.kobo/Kobo/Kobo eReader.conf`
|
||||
3. Add the following configuration:
|
||||
|
||||
```ini
|
||||
[FeatureSettings]
|
||||
# Enable OPDS catalog
|
||||
OPDSCatalogEnabled=true
|
||||
OPDSCatalogURL=http://YOUR_COMPUTER_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog?token=YOUR_API_KEY
|
||||
|
||||
# Example:
|
||||
# OPDSCatalogURL=http://192.168.1.100:8765/opds/devices/kobo-clara-123/catalog?token=dev_abc123def456
|
||||
```
|
||||
|
||||
4. Replace:
|
||||
- `YOUR_COMPUTER_IP`: Your Bookhoard server IP
|
||||
- `YOUR_DEVICE_ID`: Your Kobo's device ID from Bookhoard Device Management
|
||||
- `YOUR_API_KEY`: Your Kobo device's API key (same as in sync URL)
|
||||
|
||||
5. Save the file and safely eject your Kobo
|
||||
|
||||
### Access OPDS Catalog on Kobo
|
||||
|
||||
1. Wake your Kobo and connect to Wi-Fi
|
||||
2. Go to **Home** → **Store** (or **Shop**)
|
||||
3. You'll see **Bookhoard** listed as a store
|
||||
4. Tap to enter the Bookhoard catalog
|
||||
|
||||
### Browse and Download Books
|
||||
|
||||
#### Browse All Books
|
||||
|
||||
1. In the Bookhoard catalog, you'll see all books from your library
|
||||
2. Browse by:
|
||||
- **Recently Added**: Latest books in your library
|
||||
- **Collections**: Books organized by collections
|
||||
- **Authors**: Books grouped by author
|
||||
- **Series**: Books in reading order
|
||||
|
||||
#### Download a Book
|
||||
|
||||
1. Tap on any book cover to see details
|
||||
2. Tap **Download** or **Add to Library**
|
||||
3. The book downloads wirelessly to your Kobo
|
||||
4. Progress bar shows download status
|
||||
5. Once downloaded, the book appears in your **Home** library
|
||||
|
||||
#### Download from Collections
|
||||
|
||||
1. In the Bookhoard catalog, tap **Collections**
|
||||
2. Select a collection (e.g., "Science Fiction")
|
||||
3. Browse books in that collection
|
||||
4. Tap to download individual books
|
||||
5. Or tap **Download All** to get entire collection
|
||||
|
||||
### OPDS Features
|
||||
|
||||
#### Format Support
|
||||
|
||||
Kobo OPDS supports:
|
||||
|
||||
- **EPUB**: Standard ebook format (recommended)
|
||||
- **KEPUB**: Kobo-optimized EPUB (better page turns, fonts)
|
||||
- **PDF**: Fixed-layout documents
|
||||
|
||||
**Automatic Conversion**: Bookhoard automatically converts EPUB to KEPUB on-the-fly for better Kobo experience.
|
||||
|
||||
#### Progress Sync
|
||||
|
||||
Books downloaded via OPDS automatically sync progress:
|
||||
|
||||
1. Download a book via OPDS
|
||||
2. Start reading on your Kobo
|
||||
3. Progress syncs to Bookhoard automatically
|
||||
4. Continue reading on any other device!
|
||||
|
||||
#### Collection to Shelf Mapping
|
||||
|
||||
Bookhoard maps your collections to Kobo shelves:
|
||||
|
||||
- Collection **"Science Fiction"** → Kobo shelf **"Sci-Fi"**
|
||||
- Collection **"To Read"** → Kobo shelf **"To Read"**
|
||||
- Customizable in Bookhoard Device Management
|
||||
|
||||
### OPDS Troubleshooting
|
||||
|
||||
#### Catalog Not Appearing
|
||||
|
||||
**Problem**: Bookhoard catalog doesn't show in Kobo store
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify OPDS URL is correct in config file
|
||||
2. Check Kobo is connected to Wi-Fi
|
||||
3. Try accessing OPDS URL in your browser
|
||||
4. Ensure device ID matches Bookhoard device ID
|
||||
5. Restart Kobo after editing config file
|
||||
|
||||
#### Download Fails
|
||||
|
||||
**Problem**: Book download starts but fails partway through
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check Wi-Fi signal strength
|
||||
2. Ensure Bookhoard server is running
|
||||
3. Verify book file exists in Bookhoard library
|
||||
4. Try downloading a smaller book first
|
||||
5. Check Bookhoard logs for errors
|
||||
|
||||
#### Book Downloads But Won't Open
|
||||
|
||||
**Problem**: Downloaded book shows error when opening
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify book format is supported (EPUB/KEPUB/PDF)
|
||||
2. Check file isn't corrupted in Bookhoard
|
||||
3. Try downloading via USB and opening
|
||||
4. Check Kobo has sufficient free storage
|
||||
5. Restart your Kobo device
|
||||
|
||||
#### Slow Download Speed
|
||||
|
||||
**Problem**: Books take too long to download
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Ensure strong Wi-Fi signal (stay near router)
|
||||
2. Use 5GHz Wi-Fi if your Kobo supports it
|
||||
3. Close other apps using bandwidth
|
||||
4. Download smaller books first
|
||||
5. Consider using USB for large books
|
||||
|
||||
### OPDS vs USB Transfer
|
||||
|
||||
| Feature | OPDS (Wireless) | USB Transfer |
|
||||
| -------------------- | ----------------------------- | ------------------------- |
|
||||
| **Convenience** | ⭐⭐⭐⭐⭐ No cable needed | ⭐⭐ Requires cable |
|
||||
| **Speed** | ⭐⭐⭐ Fast (Wi-Fi dependent) | ⭐⭐⭐⭐⭐ Very fast |
|
||||
| **Bulk Transfer** | ⭐⭐⭐ One at a time | ⭐⭐⭐⭐⭐ Many at once |
|
||||
| **Progress Sync** | ⭐⭐⭐⭐⭐ Automatic | ⭐⭐⭐⭐ After first sync |
|
||||
| **Setup Complexity** | ⭐⭐⭐ Moderate | ⭐⭐⭐⭐⭐ Simple |
|
||||
| **Reliability** | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ Excellent |
|
||||
|
||||
**Recommendation**: Use OPDS for convenience (1-5 books), use USB for bulk transfers (10+ books).
|
||||
|
||||
### Advanced OPDS Configuration
|
||||
|
||||
#### Custom Catalog Name
|
||||
|
||||
Change the name of the Bookhoard catalog on your Kobo:
|
||||
|
||||
```ini
|
||||
[OPDS]
|
||||
CatalogName=My Library
|
||||
```
|
||||
|
||||
#### Auto-Download
|
||||
|
||||
Automatically download new books added to collections:
|
||||
|
||||
```ini
|
||||
[OPDS]
|
||||
AutoDownloadEnabled=true
|
||||
AutoDownloadCollections=To Read,Recent
|
||||
```
|
||||
|
||||
#### Download Quality
|
||||
|
||||
Choose between original EPUB or converted KEPUB:
|
||||
|
||||
```ini
|
||||
[OPDS]
|
||||
PreferredFormat=kepub # Options: epub, kepub, auto
|
||||
```
|
||||
|
||||
## Sync Frequency Options
|
||||
|
||||
Configure how often Kobo syncs with Bookhoard:
|
||||
|
||||
```ini
|
||||
[Sync]
|
||||
# Sync frequency in minutes
|
||||
SyncFrequency=5 # Sync every 5 minutes (recommended)
|
||||
SyncFrequency=15 # Sync every 15 minutes
|
||||
SyncFrequency=60 # Sync every hour
|
||||
SyncFrequency=0 # Manual sync only
|
||||
```
|
||||
|
||||
**Recommended**: `SyncFrequency=5` for near real-time sync
|
||||
**Battery Saving**: `SyncFrequency=15` or `30` to reduce Wi-Fi usage
|
||||
**Manual Only**: `SyncFrequency=0` sync only when you press "Sync Now"
|
||||
|
||||
## Manual Sync
|
||||
|
||||
To manually trigger a sync on your Kobo:
|
||||
|
||||
1. Connect Kobo to Wi-Fi
|
||||
2. Go to **Settings** → **Sync & Backup**
|
||||
3. Tap **Sync Now**
|
||||
4. Wait for "Sync Complete" message
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Disable Kobo Store
|
||||
|
||||
To prevent Kobo from trying to connect to the official Kobo store:
|
||||
|
||||
```ini
|
||||
[FeatureSettings]
|
||||
KoboStoreSyncDisabled=true
|
||||
```
|
||||
|
||||
### Custom Sync URL
|
||||
|
||||
If you're running Bookhoard with a custom domain or port:
|
||||
|
||||
```ini
|
||||
[Sync]
|
||||
# Custom domain
|
||||
ServerURL=https://bookhoard.example.com/api/sync/kobo
|
||||
|
||||
# Custom port
|
||||
ServerURL=http://192.168.1.100:9000/api/sync/kobo
|
||||
|
||||
# Localhost (for testing)
|
||||
ServerURL=http://localhost:8765/api/sync/kobo
|
||||
```
|
||||
|
||||
### HTTPS Configuration
|
||||
|
||||
If you have SSL/TLS configured on Bookhoard:
|
||||
|
||||
```ini
|
||||
[Sync]
|
||||
ServerURL=https://bookhoard.yourdomain.com/api/sync/kobo/YOUR_API_KEY
|
||||
```
|
||||
|
||||
Replace `YOUR_API_KEY` with your device's API key from Bookhoard Device Management.
|
||||
|
||||
Kobo will automatically trust the certificate if properly configured.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sync Not Working
|
||||
|
||||
**Problem**: Sync doesn't happen automatically
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check Kobo is connected to Wi-Fi
|
||||
2. Verify `AutoSyncEnabled=true` in config
|
||||
3. Check `SyncFrequency` is not set to 0
|
||||
4. Test with manual sync first
|
||||
5. Check Bookhoard logs for connection attempts
|
||||
|
||||
### Connection Refused
|
||||
|
||||
**Problem**: "Connection refused" or "Server not reachable"
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify Bookhoard is running on your computer
|
||||
2. Check the server URL and IP address are correct
|
||||
3. Ensure Kobo is on same Wi-Fi network as computer
|
||||
4. Temporarily disable firewall to test
|
||||
5. Try accessing Bookhoard URL in your browser first
|
||||
|
||||
### Authentication Failed
|
||||
|
||||
**Problem**: "Authentication failed" or "Invalid API key"
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify the API key in your sync URL matches the one in Bookhoard Device Management
|
||||
2. Check that device is approved in Bookhoard (not pending)
|
||||
3. Try regenerating the API key from Device Management page
|
||||
4. Ensure the sync URL is complete (includes the API key)
|
||||
5. Copy the sync URL directly from Device Management → "Copy Sync URL" button
|
||||
|
||||
### Configuration File Not Saving
|
||||
|
||||
**Problem**: Changes to `Kobo eReader.conf` are lost
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Make sure Kobo is ejected safely after editing
|
||||
2. Check file permissions (should be writable)
|
||||
3. Try a different text editor (Notepad++, VS Code, Sublime Text)
|
||||
4. Backup the file before editing
|
||||
5. On Mac, ensure you're not editing the package directly
|
||||
|
||||
### Sync Only Works Manually
|
||||
|
||||
**Problem**: Manual sync works, but auto-sync doesn't
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify `AutoSyncEnabled=true` in config
|
||||
2. Check `SyncFrequency` is not 0
|
||||
3. Kobo only syncs when connected to Wi-Fi
|
||||
4. Some Kobo models require Wi-Fi to be manually connected
|
||||
5. Check Bookhoard device management page for connection errors
|
||||
|
||||
### Books Not Appearing in Kobo
|
||||
|
||||
**Problem**: Books added to Bookhoard don't show on Kobo
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Kobo needs books to be sideloaded (manually transferred via USB)
|
||||
2. Bookhoard syncs PROGRESS, not book files
|
||||
3. Transfer book files to Kobo's `Documents` folder via USB
|
||||
4. Kobo will then sync progress for those books with Bookhoard
|
||||
5. Check that book formats are supported by Kobo
|
||||
|
||||
### Conflicts Not Showing
|
||||
|
||||
**Problem**: Conflicts between devices aren't being detected
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check Bookhoard Conflicts page
|
||||
2. Ensure both devices have synced recently
|
||||
3. Conflicts only detected when progress differs within 5 minutes
|
||||
4. Manually sync both devices to trigger conflict detection
|
||||
5. Review conflict resolution settings
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Use HTTPS**: If deploying Bookhoard publicly, configure SSL/TLS
|
||||
2. **Strong Password**: Use a secure password for your Bookhoard account
|
||||
3. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
||||
4. **Regular Updates**: Keep Kobo firmware updated
|
||||
5. **Device Authorization**: Only approve devices you recognize
|
||||
|
||||
## Network Configuration
|
||||
|
||||
### Local Network (Recommended)
|
||||
|
||||
For home use, keep Kobo and Bookhoard on the same local network:
|
||||
|
||||
```
|
||||
Kobo Wi-Fi: 192.168.1.x
|
||||
Bookhoard: 192.168.1.x
|
||||
```
|
||||
|
||||
### Remote Access
|
||||
|
||||
For access outside your home network:
|
||||
|
||||
1. Set up port forwarding on your router (port 8765)
|
||||
2. Configure SSL/TLS on Bookhoard
|
||||
3. Use a dynamic DNS service for constant hostname
|
||||
4. Update Kobo config with public URL including API key:
|
||||
```ini
|
||||
[Sync]
|
||||
ServerURL=https://yourdomain.com/api/sync/kobo/YOUR_API_KEY
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Battery Life
|
||||
|
||||
To extend Kobo battery life:
|
||||
|
||||
1. Use longer sync intervals (15-30 minutes)
|
||||
2. Sync only on Wi-Fi (not cellular if your Kobo has it)
|
||||
3. Disable unnecessary Kobo features
|
||||
4. Keep Kobo in sleep mode when not reading
|
||||
|
||||
### Sync Speed
|
||||
|
||||
To improve sync speed:
|
||||
|
||||
1. Ensure strong Wi-Fi signal
|
||||
2. Use local network (not remote access)
|
||||
3. Keep Bookhoard and Kobo on same network
|
||||
4. Close other apps using Wi-Fi bandwidth
|
||||
5. Reduce number of books syncing at once
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Kobo Developer Documentation](https://help.kobo.com/hc/en-us)
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [KOReader Setup Guide](koreader-setup.md)
|
||||
- [Bookhoard API Reference](../../developer/api-reference.md)
|
||||
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I sync books (files) between devices?**
|
||||
A: No, Bookhoard only syncs reading progress and annotations. You must sideload book files to each device manually.
|
||||
**Q: Should I buy a Kobo to use with Bookhoard today?**
|
||||
A: Kobo devices work great with Bookhoard via KOReader. Native (stock firmware) sync is coming soon.
|
||||
|
||||
**Q: Will Kobo update automatically when I add books in Bookhoard?**
|
||||
A: No, Kobo doesn't fetch book files from Bookhoard. You must transfer books via USB.
|
||||
**Q: What happens to my KOReader setup when native sync arrives?**
|
||||
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
|
||||
|
||||
**Q: Can I use both Kobo Sync and Calibre?**
|
||||
A: Yes, but they may conflict. It's recommended to choose one sync method.
|
||||
## Additional Resources
|
||||
|
||||
**Q: What happens if I read the same book on Kobo and KOReader?**
|
||||
A: Bookhoard will detect conflicts and you can resolve them in the Conflicts UI.
|
||||
|
||||
**Q: Does Kobo sync when in sleep mode?**
|
||||
A: Only if Wi-Fi is enabled and configured to stay active during sleep.
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check the troubleshooting section above
|
||||
2. Review Kobo sync logs in device settings
|
||||
3. Check Bookhoard sync queue and device management pages
|
||||
4. Verify your configuration file is saved correctly
|
||||
5. Open an issue on the Bookhoard GitHub repository
|
||||
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Bookhoard API Reference](../../developer/api-reference.md)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-31
|
||||
**Bookhoard Version**: 1.0
|
||||
**Kobo Firmware**: 4.30.0+
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
|
||||
@@ -9,18 +9,19 @@ KOReader is an open-source e-reader application that supports a wide range of e-
|
||||
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
||||
- Kobo devices (Clara, Aura, Nia, etc.)
|
||||
- PocketBook devices
|
||||
- Android tablets and phones
|
||||
|
||||
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, make sure you have:
|
||||
|
||||
- ✅ A Bookhoard instance running and accessible on your network
|
||||
- ✅ Your Bookhoard credentials (username and password)
|
||||
- ✅ A web browser logged in to your Bookhoard account (for device approval)
|
||||
- ✅ A KOReader-compatible e-reader device
|
||||
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
|
||||
|
||||
## Installation
|
||||
## Installing KOReader
|
||||
|
||||
### Kindle Devices
|
||||
|
||||
@@ -64,469 +65,132 @@ Before you begin, make sure you have:
|
||||
- Open KOReader from your apps menu
|
||||
- Enable Wi-Fi in the network settings
|
||||
|
||||
## Device Registration
|
||||
## Connecting KOReader to Bookhoard
|
||||
|
||||
### Step 1: Get Your Bookhoard Instance URL
|
||||
Setup is done **on the server**: you approve the device from the Bookhoard web interface — no usernames, passwords, or tokens to type on the device.
|
||||
|
||||
Find your Bookhoard instance URL. This will typically be one of:
|
||||
### Step 1: Install the Bookhoard Plugin
|
||||
|
||||
- **Local Network**: `http://YOUR_COMPUTER_IP:8765`
|
||||
- **Localhost (if testing)**: `http://localhost:8765`
|
||||
- **Domain (if configured)**: `https://bookhoard.yourdomain.com`
|
||||
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
2. Copy it to your KOReader `plugins/` directory
|
||||
3. Restart KOReader
|
||||
|
||||
### Step 2: Register Your Device in Bookhoard
|
||||
### Step 2: Point the Plugin at Your Server
|
||||
|
||||
1. Log in to your Bookhoard web interface
|
||||
2. Navigate to **Device Management** → **Add New Device**
|
||||
3. Fill in the device details:
|
||||
- **Device Name**: A friendly name (e.g., "My Kindle Paperwhite")
|
||||
- **Device Type**: Select "KOReader"
|
||||
- **Device Identifier**: Enter your device's hardware ID or serial number
|
||||
- On Kindle: Settings → Device Options → Device Info → Serial Number
|
||||
- On Kobo: Settings → Device Information → Serial Number
|
||||
4. Click **Register Device**
|
||||
|
||||
You'll receive:
|
||||
|
||||
- An **Auth URL** to approve the device
|
||||
- A **Device Token** (automatically generated after approval)
|
||||
|
||||
### Step 3: Approve Your Device
|
||||
|
||||
1. **Method A: QR Code**
|
||||
- If displayed, scan the QR code with your phone's camera
|
||||
- This will open the approval page in your browser
|
||||
- Log in and click **Approve**
|
||||
|
||||
2. **Method B: Manual URL**
|
||||
- Copy the Auth URL from the registration confirmation
|
||||
- Open it in your web browser
|
||||
- Log in to your Bookhoard account
|
||||
- Click **Approve Device**
|
||||
|
||||
Your device is now registered and ready to sync!
|
||||
|
||||
## Configure KOReader Sync
|
||||
|
||||
### Step 1: Access KOReader Settings
|
||||
|
||||
1. Open KOReader on your device
|
||||
2. Tap the menu icon (≡) in the top-left corner
|
||||
3. Select **Tools** → **Calibre**
|
||||
|
||||
### Step 2: Configure Wireless Connection
|
||||
|
||||
1. **Enable Calibre Wireless Connection**: Toggle ON
|
||||
2. **Server Address**: Enter your Bookhoard instance URL
|
||||
1. Open KOReader, tap the **wrench icon** at the top
|
||||
2. Find and tap **Bookhoard sync**
|
||||
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
||||
|
||||
```
|
||||
http://YOUR_COMPUTER_IP:8765/api/sync/koreader
|
||||
http://YOUR_COMPUTER_IP:8765
|
||||
```
|
||||
|
||||
Replace `YOUR_COMPUTER_IP` with your actual IP address
|
||||
Use your server's LAN IP (or domain if you have one configured).
|
||||
|
||||
3. **Set Custom Port** (if needed): Keep default or enter `8765`
|
||||
### Step 3: Approve the Device in Bookhoard
|
||||
|
||||
### Step 3: Configure Authentication
|
||||
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation)
|
||||
2. Refresh the page — your device appears under **Pending Device Registrations**
|
||||
3. Click **Approve** to connect the device
|
||||
|
||||
1. **Authentication Method**: Select "Basic Auth"
|
||||
2. **Username**: Your Bookhoard email or username
|
||||
3. **Password**: Your Bookhoard password
|
||||
Once approved, the plugin picks up its credentials automatically — reading progress sync and OPDS catalog access are set up automatically. No further configuration is needed.
|
||||
|
||||
### Step 4: Configure Sync Settings
|
||||
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again.
|
||||
|
||||
1. **Auto Sync**: Enable for automatic sync
|
||||
2. **Sync Frequency**: Choose from:
|
||||
- Every page turn (recommended for real-time sync)
|
||||
- Every bookmark save
|
||||
- Every highlight
|
||||
- Manual only (sync when you press the sync button)
|
||||
### Auth Token (Advanced)
|
||||
|
||||
3. **What to Sync**: Enable:
|
||||
- ✅ Reading progress
|
||||
- ✅ Bookmarks
|
||||
- ✅ Highlights
|
||||
- ✅ Notes
|
||||
The Devices page shows each KOReader device's **Auth Token**. You normally never need it (the plugin receives it automatically during approval), but it can be re-entered manually in the plugin settings if you're moving a setup between devices or debugging.
|
||||
|
||||
### Step 5: Test Connection
|
||||
## What Syncs
|
||||
|
||||
1. Tap **Test Connection** in the Calibre settings
|
||||
2. You should see a success message if configured correctly
|
||||
3. If it fails:
|
||||
- Verify your device is connected to Wi-Fi
|
||||
- Check the server URL is correct
|
||||
- Ensure your Bookhoard instance is running
|
||||
- Verify username and password are correct
|
||||
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
|
||||
|
||||
## Using Sync Features
|
||||
- **Reading position** — percentage, chapter, and EPUB CFI where available
|
||||
- **Bookmarks**
|
||||
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes
|
||||
- **Notes** — standalone and attached to highlights
|
||||
|
||||
### Initial Sync
|
||||
|
||||
When you first enable sync, KOReader will:
|
||||
|
||||
1. Connect to Bookhoard
|
||||
2. Upload your current reading progress
|
||||
3. Download any annotations from the server
|
||||
4. Set up bidirectional sync for future changes
|
||||
|
||||
### Reading Progress Sync
|
||||
|
||||
As you read:
|
||||
|
||||
- Progress updates automatically sync based on your sync frequency
|
||||
- Page turns, chapter changes, and bookmark saves all trigger sync
|
||||
- Sync occurs in the background without interrupting reading
|
||||
|
||||
### Annotations Sync
|
||||
|
||||
- **Bookmarks**: Sync when created or deleted
|
||||
- **Highlights**: Sync when created, edited, or deleted
|
||||
- **Notes**: Sync when created, edited, or deleted
|
||||
- **Linked Notes**: Notes attached to highlights sync together
|
||||
|
||||
### Manual Sync
|
||||
|
||||
To manually trigger a sync:
|
||||
|
||||
1. Open the KOReader menu (≡)
|
||||
2. Select **Tools** → **Calibre**
|
||||
3. Tap **Sync Now**
|
||||
|
||||
The sync status will display:
|
||||
|
||||
- 🟢 **Synced** - All changes uploaded
|
||||
- 🟡 **Syncing...** - In progress
|
||||
- 🔴 **Failed** - Check your network connection
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Offline Mode
|
||||
|
||||
KOReader automatically handles offline scenarios:
|
||||
|
||||
1. Changes are queued locally when offline
|
||||
2. Auto-sync resumes when connected
|
||||
3. Queue processes all pending changes in priority order
|
||||
|
||||
### Checkpoint Sync
|
||||
|
||||
For better battery life, use checkpoint mode:
|
||||
|
||||
1. In KOReader Calibre settings
|
||||
2. Set **Sync Mode** to "Checkpoint"
|
||||
3. Set **Checkpoint Interval** (e.g., every 5 minutes)
|
||||
4. Syncs occur in batches instead of every action
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging if sync isn't working:
|
||||
|
||||
1. KOReader menu → Tools → Calibre
|
||||
2. Enable **Debug Logging**
|
||||
3. Sync and check logs at `/mnt/us/koreader/calibre.log`
|
||||
Books are matched automatically using UUIDs, file hashes (SHA-256, format-aware so converted files still match), file aliases, and title/author fallback. If a book can't be matched, it shows up under the device's **Unlinked Books** in Bookhoard, where you can link it manually.
|
||||
|
||||
## OPDS Wireless Book Delivery
|
||||
|
||||
### What is OPDS?
|
||||
|
||||
OPDS (Open Publication Distribution System) allows your KOReader device to **wirelessly download books** from Bookhoard - no USB cable needed!
|
||||
|
||||
### OPDS Benefits
|
||||
|
||||
- **Wireless Downloads**: Browse and download books over Wi-Fi
|
||||
- **On-Demand Access**: Your entire library at your fingertips
|
||||
- **Collection Support**: Browse and download from specific collections
|
||||
- **Automatic Progress Sync**: Downloaded books sync progress instantly
|
||||
- **Format Support**: EPUB, KEPUB, PDF, and more
|
||||
|
||||
### Enable OPDS in KOReader
|
||||
|
||||
#### Step 1: Get Your OPDS URL
|
||||
|
||||
1. Log in to Bookhoard web interface
|
||||
2. Go to **Device Management**
|
||||
3. Find your registered KOReader device
|
||||
4. Click **Show OPDS URL**
|
||||
5. Copy the URL (format: `http://YOUR_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog`)
|
||||
|
||||
#### Step 2: Add OPDS Catalog in KOReader
|
||||
|
||||
1. Open KOReader on your device
|
||||
2. Tap the **+** (plus) button on the home screen
|
||||
3. Select **OPDS Catalog**
|
||||
4. Enter catalog details:
|
||||
- **Name**: Bookhoard (or any name you prefer)
|
||||
- **URL**: Paste your OPDS URL from Step 1
|
||||
5. Tap **Save**
|
||||
|
||||
Your Bookhoard library now appears in KOReader's home screen!
|
||||
Once your device is approved, the plugin also registers Bookhoard's OPDS catalog, so you can browse and download books wirelessly — no USB cable needed.
|
||||
|
||||
### Browse and Download Books
|
||||
|
||||
#### Browse Your Library
|
||||
1. In KOReader, open the OPDS catalog list and tap **Bookhoard**
|
||||
2. Browse your library: all books, collections, and recent additions
|
||||
3. Tap a book to see details and **Download** it
|
||||
|
||||
1. Tap **Bookhoard** on KOReader home screen
|
||||
2. You'll see:
|
||||
- **All Books**: Complete library view
|
||||
- **Collections**: Books organized by collections
|
||||
- **Recent**: Latest additions
|
||||
3. Tap any category to browse
|
||||
|
||||
#### Download a Book
|
||||
|
||||
1. Browse to find a book
|
||||
2. Tap the book to see details
|
||||
3. Tap **Download**
|
||||
4. Progress bar shows download status
|
||||
5. Book opens automatically when complete
|
||||
|
||||
#### Download Entire Collections
|
||||
|
||||
1. In Bookhoard catalog, tap **Collections**
|
||||
2. Select a collection
|
||||
3. Tap **Download All** to get all books
|
||||
4. Downloads queue and process in background
|
||||
|
||||
### OPDS Features
|
||||
|
||||
#### Supported Formats
|
||||
|
||||
KOReader OPDS supports:
|
||||
### Supported Formats
|
||||
|
||||
- **EPUB**: Standard ebook format
|
||||
- **KEPUB**: Kobo-optimized format (KOReader handles this well)
|
||||
- **KEPUB**: Kobo-optimized format
|
||||
- **PDF**: Fixed-layout documents
|
||||
- **CBZ**: Comic book archives
|
||||
- **TXT**: Plain text files
|
||||
- **RTF**: Rich text format
|
||||
|
||||
#### Automatic Book Matching
|
||||
|
||||
Books downloaded via OPDS are automatically matched:
|
||||
|
||||
- Uses SHA-256 hashes for precise matching
|
||||
- Falls back to title/author matching
|
||||
- Links to your existing Bookhoard library
|
||||
- Progress syncs automatically
|
||||
|
||||
#### Collection Integration
|
||||
|
||||
Your Bookhoard collections appear in KOReader:
|
||||
|
||||
- Collection **"To Read"** → KOReader category
|
||||
- Collection **"Science Fiction"** → Browseable section
|
||||
- Custom collections → Preserved organization
|
||||
|
||||
### KOReader OPDS Settings
|
||||
|
||||
#### Update Interval
|
||||
|
||||
Configure how often KOReader checks for new books:
|
||||
|
||||
1. KOReader menu → Tools → OPDS
|
||||
2. Set **Update Interval**: 5min, 15min, 1hr, manual
|
||||
3. **Recommended**: 15min for balance
|
||||
|
||||
#### Download Location
|
||||
|
||||
Choose where to store downloaded books:
|
||||
|
||||
1. KOReader menu → File Browser
|
||||
2. Set **Default Download Folder**
|
||||
3. **Recommended**: `/mnt/us/Documents/` (Kindle) or `/mnt/onboard/Documents/` (Kobo)
|
||||
|
||||
#### Auto-Download
|
||||
|
||||
Automatically download new books from collections:
|
||||
|
||||
1. KOReader menu → Tools → OPDS
|
||||
2. Enable **Auto-Download New Books**
|
||||
3. Select collections to monitor
|
||||
4. New books download automatically when connected to Wi-Fi
|
||||
|
||||
### OPDS Troubleshooting
|
||||
|
||||
#### Catalog Not Loading
|
||||
|
||||
**Problem**: Bookhoard catalog shows error or won't load
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify device is connected to Wi-Fi
|
||||
2. Check OPDS URL is correct in settings
|
||||
3. Try accessing OPDS URL in your browser
|
||||
4. Ensure Bookhoard server is running
|
||||
5. Check Bookhoard device is approved
|
||||
|
||||
#### Download Fails
|
||||
|
||||
**Problem**: Book download starts but fails
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check Wi-Fi signal strength
|
||||
2. Ensure sufficient storage on device
|
||||
3. Try downloading a smaller book
|
||||
4. Check Bookhoard has the book file
|
||||
5. Review Bookhoard logs for errors
|
||||
|
||||
#### Book Opens But Progress Doesn't Sync
|
||||
|
||||
**Problem**: Downloaded book doesn't sync progress
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify book is matched to Bookhoard library
|
||||
2. Check device sync settings are enabled
|
||||
3. Try manual sync from device
|
||||
4. Ensure book exists in Bookhoard with same hash
|
||||
5. Check Bookhoard Progress page
|
||||
|
||||
#### Slow Downloads
|
||||
|
||||
**Problem**: Books take too long to download
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Stay close to Wi-Fi router
|
||||
2. Use 5GHz Wi-Fi if available
|
||||
3. Close other apps using bandwidth
|
||||
4. Download smaller books first
|
||||
5. Consider USB for large books (100MB+)
|
||||
|
||||
### Advanced OPDS Configuration
|
||||
|
||||
#### Custom User-Agent
|
||||
|
||||
Some OPDS catalogs require specific user agent:
|
||||
|
||||
```lua
|
||||
-- In KOReader settings
|
||||
OPDSUserAgent = "KOReader/2024.01"
|
||||
```
|
||||
|
||||
#### Authentication Token
|
||||
|
||||
If Bookhoard requires token authentication:
|
||||
|
||||
1. Get token from Bookhoard device settings
|
||||
2. Add to OPDS URL: `?token=YOUR_TOKEN`
|
||||
3. KOReader includes token in all requests
|
||||
|
||||
#### Compression
|
||||
|
||||
Enable compression for faster downloads:
|
||||
|
||||
```lua
|
||||
-- In KOReader settings
|
||||
OPDSCompressionEnabled = true
|
||||
```
|
||||
|
||||
### OPDS vs USB Transfer
|
||||
|
||||
| Feature | OPDS (Wireless) | USB Transfer |
|
||||
| ----------------- | ----------------------------- | ----------------------- |
|
||||
| **Convenience** | ⭐⭐⭐⭐⭐ No cable needed | ⭐⭐ Requires cable |
|
||||
| **Speed** | ⭐⭐⭐ Fast (Wi-Fi dependent) | ⭐⭐⭐⭐⭐ Very fast |
|
||||
| **Bulk Transfer** | ⭐⭐⭐ One at a time | ⭐⭐⭐⭐⭐ Many at once |
|
||||
| **Progress Sync** | ⭐⭐⭐⭐⭐ Instant | ⭐⭐⭐⭐ After transfer |
|
||||
| **Accessibility** | ⭐⭐⭐⭐⭐ Anywhere | ⭐⭐ At computer only |
|
||||
| **Reliability** | ⭐⭐⭐⭐ Very good | ⭐⭐⭐⭐⭐ Excellent |
|
||||
|
||||
**Recommendation**: Use OPDS for daily reading (convenience), USB for bulk library transfers.
|
||||
|
||||
### OPDS Tips and Tricks
|
||||
|
||||
1. **Favorite Collections**: Pin frequently-used collections to home screen
|
||||
2. **Batch Downloads**: Start multiple downloads before leaving Wi-Fi
|
||||
3. **Download Queue**: Downloads continue in background while reading
|
||||
4. **Storage Management**: Check free space before downloading large collections
|
||||
5. **Network Speed**: Use 5GHz Wi-Fi for faster downloads if available
|
||||
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pending Registration Never Appears
|
||||
|
||||
**Problem**: You entered the Server URL, but no pending registration shows in Bookhoard
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Verify the Server URL is correct (no trailing path — just the base address)
|
||||
2. Make sure KOReader is connected to Wi-Fi
|
||||
3. Check the Bookhoard server is reachable from the device's network
|
||||
4. Registrations expire after 5 minutes — re-run the sync and approve quickly
|
||||
|
||||
### Connection Refused
|
||||
|
||||
**Problem**: "Connection refused" error
|
||||
**Problem**: "Connection refused" error on the device
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Verify Bookhoard is running on your computer
|
||||
- Check the server URL and port (8765)
|
||||
- Ensure device is on same Wi-Fi network
|
||||
- Try using your computer's IP address instead of "localhost"
|
||||
- Verify Bookhoard is running
|
||||
- Check the server address and port (default `8765`)
|
||||
- Ensure the device is on the same Wi-Fi network as the server
|
||||
- Use the server's LAN IP instead of `localhost`
|
||||
|
||||
### Authentication Failed
|
||||
### Sync Not Working After Approval
|
||||
|
||||
**Problem**: "Authentication failed" error
|
||||
**Problem**: Device shows as approved but changes don't appear in Bookhoard
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Verify username and password
|
||||
- Check your account is active and not locked
|
||||
- Try logging in to Bookhoard web interface first
|
||||
- Reset password if needed
|
||||
|
||||
### Sync Not Working
|
||||
|
||||
**Problem**: Changes not appearing in Bookhoard
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Enable debug logging in KOReader
|
||||
- Check Bookhoard Device Management page for errors
|
||||
- Verify sync is enabled in KOReader settings
|
||||
- Try manual sync to trigger immediate update
|
||||
- Check Bookhoard logs for sync errors
|
||||
- Trigger a manual sync from the plugin menu
|
||||
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
|
||||
- Verify the book appears as an unlinked book for the device and link it if needed
|
||||
- Check Bookhoard server logs for errors
|
||||
|
||||
### Conflicts Detected
|
||||
|
||||
**Problem**: Sync conflicts when reading on multiple devices
|
||||
**Problem**: Sync conflicts when reading the same book on multiple devices
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Go to Bookhoard **Conflicts** page
|
||||
2. Review conflicting progress from each device
|
||||
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
|
||||
2. Review the progress reported by each device
|
||||
3. Choose which device's progress to keep
|
||||
4. Set auto-resolution preference for future conflicts
|
||||
|
||||
### Large Files Not Syncing
|
||||
|
||||
**Problem**: Large annotations or highlights fail to sync
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Check Bookhoard sync queue for stuck items
|
||||
- Increase sync timeout in KOReader settings
|
||||
- Break up large highlights into smaller segments
|
||||
- Verify network bandwidth is sufficient
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Use HTTPS**: If deploying Bookhoard publicly, configure SSL/TLS
|
||||
2. **Strong Password**: Use a secure password for your Bookhoard account
|
||||
3. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
||||
4. **Device Authorization**: Only approve devices you recognize
|
||||
5. **Regular Updates**: Keep KOReader updated to the latest version
|
||||
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
|
||||
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
||||
3. **Device Authorization**: Only approve pending registrations you initiated
|
||||
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [KOReader Documentation](https://github.com/koreader/koreader)
|
||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Kobo Setup Guide](kobo-setup.md)
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check the troubleshooting section above
|
||||
2. Enable debug logging and review KOReader logs
|
||||
3. Check Bookhoard sync queue and device management pages
|
||||
4. Open an issue on the Bookhoard GitHub repository
|
||||
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-31
|
||||
**Bookhoard Version**: 1.0
|
||||
**KOReader Version**: 2024.01+
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
|
||||
@@ -1,60 +1,58 @@
|
||||
## Saving Custom Filters
|
||||
# Saving Custom Filters
|
||||
|
||||
The bookshelf page allows you to save custom filter presets for quick access.
|
||||
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
|
||||
|
||||
### How to Save a Filter
|
||||
## Bookshelf Toolbar
|
||||
|
||||
The All Books page has a toolbar with:
|
||||
|
||||
- A **search input** for quick text searches
|
||||
- A **sort** dropdown (title, author, date added, page count)
|
||||
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
|
||||
- **Save**, **Load**, and **Clear** buttons for filter presets
|
||||
|
||||
## How to Save a Filter
|
||||
|
||||
1. Navigate to the **All Books** page
|
||||
2. Set your desired filters (genre, author, series, etc.)
|
||||
3. Click the **💾 Save Filter** button
|
||||
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
|
||||
3. Click the **Save** button in the toolbar
|
||||
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
||||
5. Click **Save**
|
||||
|
||||
### Loading Saved Filters
|
||||
## Loading Saved Filters
|
||||
|
||||
After saving filters, you can quickly load them from the saved filters dropdown:
|
||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||
2. Click a filter's name to apply it
|
||||
3. The filter values are applied instantly, without a page reload
|
||||
|
||||
1. Click the **📋 Saved Filters** button (next to the Save Filter button)
|
||||
2. Select a filter from the dropdown list
|
||||
3. The filter values are automatically applied to the form
|
||||
4. Your books are instantly filtered to show matching results
|
||||
## Managing Saved Filters
|
||||
|
||||
**Tips:**
|
||||
- Saved filters appear in the dropdown with their names
|
||||
- Hover over a filter to see a delete button (🗑️)
|
||||
- Click a filter name to apply it instantly
|
||||
- Filters are applied without page reload (instant feedback)
|
||||
**Delete a filter:**
|
||||
|
||||
### Managing Saved Filters
|
||||
|
||||
**View Saved Filters:**
|
||||
- Saved filters are displayed in the dropdown
|
||||
- Each filter shows its name (e.g., "My Sci-Fi Books")
|
||||
|
||||
**Delete a Filter:**
|
||||
1. Click the **📋 Saved Filters** button
|
||||
2. Hover over the filter you want to delete
|
||||
3. Click the **🗑️** delete button
|
||||
4. Confirm deletion
|
||||
5. The filter is removed from your list
|
||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||
2. Click the trash icon next to the filter you want to remove
|
||||
3. Confirm deletion
|
||||
|
||||
**Filter Privacy:**
|
||||
|
||||
Saved filters are **private to your account**. Other users cannot see or modify your filters.
|
||||
|
||||
### Common Use Cases
|
||||
## Common Use Cases
|
||||
|
||||
**Reading by Genre:**
|
||||
1. Filter by genre: "Science Fiction"
|
||||
|
||||
1. Filter by tag: "Science Fiction"
|
||||
2. Save as "Sci-Fi Books"
|
||||
3. Quickly access all your sci-fi collection anytime
|
||||
|
||||
**Author Collections:**
|
||||
|
||||
1. Filter by author: "Isaac Asimov"
|
||||
2. Save as "Asimov Books"
|
||||
3. Switch between different author collections instantly
|
||||
|
||||
**Series Tracking:**
|
||||
|
||||
1. Filter by series: "Foundation"
|
||||
2. Save as "Foundation Series"
|
||||
3. Track your progress through a series
|
||||
|
||||
@@ -6,10 +6,10 @@ Your profile contains your account information and preferences.
|
||||
|
||||
### How to Update
|
||||
|
||||
1. Click on your **username** (top-right)
|
||||
2. Select **Profile** from the dropdown
|
||||
1. Click on your **username** at the bottom of the sidebar to expand the account menu
|
||||
2. Select **Profile**
|
||||
3. Edit any fields in the "Account Information" section
|
||||
4. Click **Update Profile**
|
||||
4. Click **Save Changes**
|
||||
5. Changes take effect immediately
|
||||
|
||||
### Fields You Can Update
|
||||
@@ -65,7 +65,7 @@ When you delete your account:
|
||||
1. Go to **Profile** page
|
||||
2. Scroll to "Danger Zone" (bottom of page)
|
||||
3. Click **Remove My Account**
|
||||
4. Confirm by clicking "OK" in the popup
|
||||
4. Confirm the deletion prompt
|
||||
|
||||
**Note:** If you're the last admin, you cannot delete your account for security reasons.
|
||||
|
||||
@@ -75,10 +75,12 @@ Personalize your reading experience with different color themes.
|
||||
|
||||
### Quick Theme Switch
|
||||
|
||||
1. Click the **paintbrush icon** (top-right, next to your username)
|
||||
2. Select a theme from the dropdown
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Select a theme from the list; your active theme is marked with a checkmark
|
||||
3. Changes apply instantly
|
||||
|
||||
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
|
||||
|
||||
### Available Themes
|
||||
|
||||
- **Tokyo Night** (default) - Blue/purple accents
|
||||
@@ -88,9 +90,7 @@ Personalize your reading experience with different color themes.
|
||||
- **Monokai** - Classic vibrant colors
|
||||
- **One Dark Pro** - Atom editor inspired
|
||||
- **Material Dark** - Google Material Design
|
||||
- **Wood Light** - Light wood texture
|
||||
- **Wood Dark** - Dark wood texture
|
||||
- **Wood Mahogany** - Reddish-brown wood
|
||||
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
|
||||
|
||||
## For Admin Users
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ Tags are keywords or categories assigned to books, such as:
|
||||
|
||||
### Filtering by Tags
|
||||
|
||||
1. Navigate to the **Bookshelf** page
|
||||
2. Use the **Tags** filter input
|
||||
3. Start typing to see autocomplete suggestions
|
||||
4. Select a tag or press Enter to filter
|
||||
1. Navigate to the **All Books** page
|
||||
2. Click **Filters** in the toolbar to open the filter drawer
|
||||
3. Use the **Tags** filter input
|
||||
4. Start typing to see autocomplete suggestions
|
||||
5. Select a tag or press Enter, then click **Apply Filters**
|
||||
|
||||
**Example:** Typing "Sci" will suggest "Science Fiction"
|
||||
|
||||
|
||||
+43
-40
@@ -21,7 +21,7 @@
|
||||
|
||||
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
|
||||
|
||||
📱 **Multi-Platform** - Works with web browsers, KOReader, Kobo devices, and mobile apps
|
||||
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap
|
||||
|
||||
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
|
||||
|
||||
@@ -37,19 +37,24 @@
|
||||
|
||||
### Currently Supported ✅
|
||||
|
||||
| Platform | Status | Sync Method | Notes |
|
||||
| ---------------- | ------------------ | --------------------------- | ------------------------------ |
|
||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Calibre-compatible) | Kindle, Kobo, PocketBook, etc. |
|
||||
| **Kobo Devices** | ✅ Fully Supported | Wi-Fi (Kobo API-compatible) | Clara, Libra, Sage, etc. |
|
||||
| Platform | Status | Sync Method | Notes |
|
||||
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
|
||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
|
||||
|
||||
### Coming Soon 🚧
|
||||
|
||||
| Platform | Expected Release |
|
||||
| --------------------- | ---------------- |
|
||||
| **Mobile Apps** | Q2 2026 |
|
||||
| **Kindle Devices** | Q3 2026 |
|
||||
| **Remarkable Tablet** | Q4 2026 |
|
||||
| Platform | Status |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
|
||||
| **Mobile Apps** | Android/iOS apps coming later |
|
||||
|
||||
### On the Roadmap 🔭
|
||||
|
||||
| Platform | Status |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| **Kindle Devices** | Under consideration (no date yet) |
|
||||
| **Remarkable Tablet** | Under consideration (no date yet) |
|
||||
|
||||
---
|
||||
|
||||
@@ -74,22 +79,21 @@
|
||||
|
||||
For detailed device configuration instructions, see the appropriate setup guide:
|
||||
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Kobo e-reader configuration
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
|
||||
|
||||
### Quick Overview
|
||||
|
||||
**Registration Process**:
|
||||
**Registration Process** (KOReader):
|
||||
|
||||
1. Register device in Bookhoard web interface (Settings → Devices)
|
||||
2. Approve device via QR code or approval URL
|
||||
3. Configure sync settings on your device
|
||||
4. Start reading - progress syncs automatically!
|
||||
1. Install the Bookhoard plugin and enter your server URL in KOReader
|
||||
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
|
||||
3. That's it — sync starts automatically once approved
|
||||
|
||||
**Device Management**:
|
||||
|
||||
```
|
||||
Settings → Devices
|
||||
Devices page (sidebar navigation)
|
||||
```
|
||||
|
||||
You can:
|
||||
@@ -124,7 +128,7 @@ Sometimes a book on your device can't be automatically matched to your library.
|
||||
### Viewing Unlinked Books
|
||||
|
||||
```
|
||||
Settings → Devices → Select Device → View Unlinked Books
|
||||
Devices page → select device → unlinked books
|
||||
```
|
||||
|
||||
### Resolving Unlinked Books
|
||||
@@ -284,7 +288,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check device is online: `Settings → Devices`
|
||||
1. Check device is online: Devices page (sidebar navigation)
|
||||
2. Verify sync is enabled for the device
|
||||
3. Check sync URL is correct
|
||||
4. Ensure device has network connection
|
||||
@@ -318,7 +322,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Go to `Settings → Conflicts`
|
||||
1. Open the book's detail page and click **Sync Progress**, or go to the Conflicts page (`/conflicts`)
|
||||
2. Review both device progress
|
||||
3. Choose which device's progress to keep
|
||||
4. Or choose "Merge" (keeps furthest progress)
|
||||
@@ -331,7 +335,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
|
||||
1. Switch to checkpoint mode
|
||||
2. Increase sync interval
|
||||
3. Use Wi-Fi instead of cellular (for mobile)
|
||||
3. Sync less frequently
|
||||
|
||||
---
|
||||
|
||||
@@ -341,7 +345,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
|
||||
✅ **DO**:
|
||||
|
||||
- Use checkpoint mode when on cellular data
|
||||
- Use checkpoint mode when on slow connections
|
||||
- Keep device firmware updated
|
||||
- Use Wi-Fi when available
|
||||
- Approve only devices you own
|
||||
@@ -369,7 +373,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
|
||||
- **Primary Device**: KOReader on e-reader
|
||||
- **Secondary Device**: Web browser (work/home)
|
||||
- **Mobile Device**: Phone app (commute)
|
||||
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
|
||||
|
||||
**Sync Strategy**:
|
||||
|
||||
@@ -393,7 +397,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
**Manual Resolution**:
|
||||
|
||||
```
|
||||
Settings → Conflicts → Select conflict → Choose winner
|
||||
Book detail → Sync Progress → choose winner
|
||||
```
|
||||
|
||||
**Options**:
|
||||
@@ -408,7 +412,7 @@ Settings → Conflicts → Select conflict → Choose winner
|
||||
**View Queue Status**:
|
||||
|
||||
```
|
||||
Settings → Devices → Select Device → View Queue
|
||||
Devices page → sync queue section
|
||||
```
|
||||
|
||||
**Queue Stats**:
|
||||
@@ -436,7 +440,7 @@ Settings → Devices → Select Device → View Queue
|
||||
**View History**:
|
||||
|
||||
```
|
||||
Book → Reading History
|
||||
Progress page (sidebar navigation), or the book's detail page
|
||||
```
|
||||
|
||||
**Privacy**:
|
||||
@@ -503,9 +507,8 @@ Book → Reading History
|
||||
### For Better Battery Life
|
||||
|
||||
1. **Checkpoint mode** - Fewer sync requests
|
||||
2. **Wi-Fi only** - Disable cellular
|
||||
3. **Increase sync interval** - Fewer updates
|
||||
4. **Close when not reading** - Reduces background activity
|
||||
2. **Increase sync interval** - Fewer updates
|
||||
3. **Close when not reading** - Reduces background activity
|
||||
|
||||
---
|
||||
|
||||
@@ -523,7 +526,7 @@ A: No, devices are tied to individual accounts for security.
|
||||
A: All sync data for that book is removed from the server.
|
||||
|
||||
**Q: Can I export my reading data?**
|
||||
A: Yes! Settings → Export → Download sync data.
|
||||
A: Reading data isn't exportable from the UI yet — it's accessible via the API.
|
||||
|
||||
**Q: Does sync work over the internet?**
|
||||
A: Yes, if your server is publicly accessible with HTTPS.
|
||||
@@ -537,7 +540,7 @@ A: Approximately 1KB per page turn, 50KB per annotation.
|
||||
A: Uses percentage and EPUB CFI for universal positioning.
|
||||
|
||||
**Q: Can I sync with Calibre anymore?**
|
||||
A: Yes! KOReader sync is Calibre-compatible.
|
||||
A: Bookhoard's KOReader sync uses a dedicated plugin (server-side approval, device tokens) — no Calibre involvement required.
|
||||
|
||||
**Q: What if I lose my device?**
|
||||
A: Revoke it in settings and register a new one.
|
||||
@@ -571,18 +574,18 @@ A: Yes, HTTPS/TLS 1.3 for all sync traffic.
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0.0 (January 2026)
|
||||
### Version 1.0.x (2026)
|
||||
|
||||
- ✅ Initial release
|
||||
- ✅ KOReader sync support
|
||||
- ✅ Kobo device support
|
||||
- ✅ Web sync support
|
||||
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
|
||||
- ✅ Conflict resolution
|
||||
- ✅ Offline queue
|
||||
- ✅ Real-time WebSocket sync
|
||||
- 🚧 Native Kobo sync (coming soon)
|
||||
- 🚧 Mobile apps (coming later)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 31, 2026
|
||||
**Version**: 1.0.0
|
||||
**License**: MIT
|
||||
**Last Updated**: August 2026
|
||||
**Version**: 1.0
|
||||
**License**: GPL-3.0
|
||||
|
||||
+8
-6
@@ -18,10 +18,12 @@ Bookhoard includes multiple color themes to suit your preferences:
|
||||
|
||||
### Changing Your Theme
|
||||
|
||||
1. Click the theme icon (palette) in the header
|
||||
2. Select your preferred color theme
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Pick a theme from the list — each option shows its color swatch, and your active theme is marked with a checkmark
|
||||
3. Your choice is saved automatically and synced across devices
|
||||
|
||||
On small screens, open the sidebar with the menu button in the top bar first.
|
||||
|
||||
## Wood Paneling
|
||||
|
||||
Wood paneling adds texture to your dashboard bookshelf background, giving it a classic bookshelf feel.
|
||||
@@ -35,10 +37,10 @@ Wood paneling adds texture to your dashboard bookshelf background, giving it a c
|
||||
|
||||
### Applying Wood Paneling
|
||||
|
||||
1. Click the theme icon (palette) in the header
|
||||
2. Scroll to "Bookshelf Background" section
|
||||
3. Select your preferred wood texture
|
||||
4. Texture is applied to dashboard bookshelf only
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Scroll to the **Bookshelf** section below the theme list
|
||||
3. Select your preferred wood texture (each option shows a texture swatch; **None** is the default)
|
||||
4. Texture is applied to the dashboard bookshelf background
|
||||
|
||||
**Note:** Wood paneling is a browser preference and is not synced across devices.
|
||||
|
||||
|
||||
@@ -6,18 +6,16 @@ Welcome to the Bookhoard user documentation. This section contains guides for us
|
||||
|
||||
Learn how to configure your e-reader devices to sync with Bookhoard:
|
||||
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Complete guide for Kobo e-readers
|
||||
- Device registration
|
||||
- Sync configuration
|
||||
- OPDS wireless book delivery
|
||||
- Troubleshooting
|
||||
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
||||
- Installation on Kindle/Kobo/PocketBook
|
||||
- Sync setup
|
||||
- Plugin setup with server-side device approval
|
||||
- Progress, bookmark, highlight, and note sync
|
||||
- OPDS catalog access
|
||||
- Troubleshooting
|
||||
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
|
||||
- In the meantime, KOReader works great on Kobo hardware
|
||||
|
||||
## 🔄 Sync Configuration
|
||||
|
||||
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features
|
||||
|
||||
@@ -327,6 +327,14 @@ type Querier interface {
|
||||
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
||||
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||
// ============================================
|
||||
// ANNOTATION HISTORY (deleted-annotation archive)
|
||||
// ============================================
|
||||
// Lists every currently-tombstoned annotation for a book regardless of the
|
||||
// tombstone TTL: this backs the book page's "recently deleted" history where
|
||||
// users can restore or permanently remove entries. Rows whose tombstones have
|
||||
// been purged by the daily maintenance sweep no longer exist at all.
|
||||
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
@@ -348,6 +356,11 @@ type Querier interface {
|
||||
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeMediaBookmarkByID(ctx context.Context, arg PurgeMediaBookmarkByIDParams) (int64, error)
|
||||
// Permanent removal from the history (distinct from the TTL-driven purge,
|
||||
// which is maintenance). Scoped to the owning user and book.
|
||||
PurgeMediaHighlightByID(ctx context.Context, arg PurgeMediaHighlightByIDParams) (int64, error)
|
||||
PurgeMediaNoteByID(ctx context.Context, arg PurgeMediaNoteByIDParams) (int64, error)
|
||||
// Query media items by multiple identifiers with confidence scoring
|
||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||
@@ -363,6 +376,9 @@ type Querier interface {
|
||||
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
||||
// Resolve unlinked book
|
||||
ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error)
|
||||
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
|
||||
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
|
||||
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
|
||||
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
|
||||
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
||||
// Revoke OPDS token
|
||||
|
||||
@@ -6852,7 +6852,11 @@ SELECT
|
||||
mh.dedup_key,
|
||||
'highlight' as annotation_type,
|
||||
mh.device_sync_data,
|
||||
mh.deleted_at
|
||||
mh.deleted_at,
|
||||
mh.start_position,
|
||||
mh.end_position,
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -6861,7 +6865,11 @@ SELECT
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
mn.device_sync_data,
|
||||
mn.deleted_at
|
||||
mn.deleted_at,
|
||||
mn.position as start_position,
|
||||
NULL as end_position,
|
||||
mn.epubcfi_location as epubcfi_start,
|
||||
NULL as epubcfi_end
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -6870,7 +6878,11 @@ SELECT
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
mb.device_sync_data,
|
||||
mb.deleted_at
|
||||
mb.deleted_at,
|
||||
mb.position as start_position,
|
||||
NULL as end_position,
|
||||
mb.cfi_position as epubcfi_start,
|
||||
NULL as epubcfi_end
|
||||
FROM media_bookmarks mb
|
||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
||||
ORDER BY deleted_at DESC
|
||||
@@ -6888,6 +6900,10 @@ type GetTombstonedAnnotationsForBookRow struct {
|
||||
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) {
|
||||
@@ -6905,6 +6921,10 @@ func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTo
|
||||
&i.AnnotationType,
|
||||
&i.DeviceSyncData,
|
||||
&i.DeletedAt,
|
||||
&i.StartPosition,
|
||||
&i.EndPosition,
|
||||
&i.EpubcfiStart,
|
||||
&i.EpubcfiEnd,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -8107,6 +8127,98 @@ func (q *Queries) ListConflictsByUser(ctx context.Context, userID pgtype.UUID) (
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListDeletedAnnotationsForBook = `-- name: ListDeletedAnnotationsForBook :many
|
||||
|
||||
SELECT
|
||||
mh.id,
|
||||
mh.dedup_key,
|
||||
'highlight' as annotation_type,
|
||||
mh.selection_text as display_text,
|
||||
mh.note_text as secondary_text,
|
||||
mh.color,
|
||||
mh.deleted_at,
|
||||
mh.created_at
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
mn.content as display_text,
|
||||
NULL::text as secondary_text,
|
||||
NULL::text as color,
|
||||
mn.deleted_at,
|
||||
mn.created_at
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mb.id,
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
mb.title as display_text,
|
||||
mb.notes as secondary_text,
|
||||
NULL::text as color,
|
||||
mb.deleted_at,
|
||||
mb.created_at
|
||||
FROM media_bookmarks mb
|
||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE
|
||||
ORDER BY deleted_at DESC
|
||||
`
|
||||
|
||||
type ListDeletedAnnotationsForBookParams struct {
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
type ListDeletedAnnotationsForBookRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
AnnotationType string `db:"annotation_type" json:"annotation_type"`
|
||||
DisplayText string `db:"display_text" json:"display_text"`
|
||||
SecondaryText pgtype.Text `db:"secondary_text" json:"secondary_text"`
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ANNOTATION HISTORY (deleted-annotation archive)
|
||||
// ============================================
|
||||
// Lists every currently-tombstoned annotation for a book regardless of the
|
||||
// tombstone TTL: this backs the book page's "recently deleted" history where
|
||||
// users can restore or permanently remove entries. Rows whose tombstones have
|
||||
// been purged by the daily maintenance sweep no longer exist at all.
|
||||
func (q *Queries) ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListDeletedAnnotationsForBook, arg.MediaItemID, arg.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListDeletedAnnotationsForBookRow{}
|
||||
for rows.Next() {
|
||||
var i ListDeletedAnnotationsForBookRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.DedupKey,
|
||||
&i.AnnotationType,
|
||||
&i.DisplayText,
|
||||
&i.SecondaryText,
|
||||
&i.Color,
|
||||
&i.DeletedAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListDevicesByType = `-- name: ListDevicesByType :many
|
||||
SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE device_type = $1 ORDER BY created_at DESC
|
||||
`
|
||||
@@ -9399,6 +9511,65 @@ func (q *Queries) PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgty
|
||||
return err
|
||||
}
|
||||
|
||||
const PurgeMediaBookmarkByID = `-- name: PurgeMediaBookmarkByID :execrows
|
||||
DELETE FROM media_bookmarks
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
||||
`
|
||||
|
||||
type PurgeMediaBookmarkByIDParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) PurgeMediaBookmarkByID(ctx context.Context, arg PurgeMediaBookmarkByIDParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, PurgeMediaBookmarkByID, arg.ID, arg.UserID, arg.MediaItemID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const PurgeMediaHighlightByID = `-- name: PurgeMediaHighlightByID :execrows
|
||||
DELETE FROM media_highlights
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
||||
`
|
||||
|
||||
type PurgeMediaHighlightByIDParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
}
|
||||
|
||||
// Permanent removal from the history (distinct from the TTL-driven purge,
|
||||
// which is maintenance). Scoped to the owning user and book.
|
||||
func (q *Queries) PurgeMediaHighlightByID(ctx context.Context, arg PurgeMediaHighlightByIDParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, PurgeMediaHighlightByID, arg.ID, arg.UserID, arg.MediaItemID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const PurgeMediaNoteByID = `-- name: PurgeMediaNoteByID :execrows
|
||||
DELETE FROM media_notes
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
||||
`
|
||||
|
||||
type PurgeMediaNoteByIDParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) PurgeMediaNoteByID(ctx context.Context, arg PurgeMediaNoteByIDParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, PurgeMediaNoteByID, arg.ID, arg.UserID, arg.MediaItemID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const QueryMediaItemsByIdentifiers = `-- name: QueryMediaItemsByIdentifiers :many
|
||||
SELECT
|
||||
mi.id,
|
||||
@@ -9732,6 +9903,72 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
|
||||
return i, err
|
||||
}
|
||||
|
||||
const RestoreMediaBookmarkByID = `-- name: RestoreMediaBookmarkByID :execrows
|
||||
UPDATE media_bookmarks SET
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL,
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
||||
`
|
||||
|
||||
type RestoreMediaBookmarkByIDParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, RestoreMediaBookmarkByID, arg.ID, arg.UserID, arg.MediaItemID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const RestoreMediaHighlightByID = `-- name: RestoreMediaHighlightByID :execrows
|
||||
UPDATE media_highlights SET
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL,
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
||||
`
|
||||
|
||||
type RestoreMediaHighlightByIDParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, RestoreMediaHighlightByID, arg.ID, arg.UserID, arg.MediaItemID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const RestoreMediaNoteByID = `-- name: RestoreMediaNoteByID :execrows
|
||||
UPDATE media_notes SET
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL,
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE
|
||||
`
|
||||
|
||||
type RestoreMediaNoteByIDParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, RestoreMediaNoteByID, arg.ID, arg.UserID, arg.MediaItemID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
|
||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
|
||||
`
|
||||
|
||||
@@ -992,7 +992,11 @@ SELECT
|
||||
mh.dedup_key,
|
||||
'highlight' as annotation_type,
|
||||
mh.device_sync_data,
|
||||
mh.deleted_at
|
||||
mh.deleted_at,
|
||||
mh.start_position,
|
||||
mh.end_position,
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -1001,7 +1005,11 @@ SELECT
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
mn.device_sync_data,
|
||||
mn.deleted_at
|
||||
mn.deleted_at,
|
||||
mn.position as start_position,
|
||||
NULL as end_position,
|
||||
mn.epubcfi_location as epubcfi_start,
|
||||
NULL as epubcfi_end
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||
UNION ALL
|
||||
@@ -1010,11 +1018,96 @@ SELECT
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
mb.device_sync_data,
|
||||
mb.deleted_at
|
||||
mb.deleted_at,
|
||||
mb.position as start_position,
|
||||
NULL as end_position,
|
||||
mb.cfi_position as epubcfi_start,
|
||||
NULL as epubcfi_end
|
||||
FROM media_bookmarks mb
|
||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
||||
ORDER BY deleted_at DESC;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION HISTORY (deleted-annotation archive)
|
||||
-- ============================================
|
||||
|
||||
-- Lists every currently-tombstoned annotation for a book regardless of the
|
||||
-- tombstone TTL: this backs the book page's "recently deleted" history where
|
||||
-- users can restore or permanently remove entries. Rows whose tombstones have
|
||||
-- been purged by the daily maintenance sweep no longer exist at all.
|
||||
-- name: ListDeletedAnnotationsForBook :many
|
||||
SELECT
|
||||
mh.id,
|
||||
mh.dedup_key,
|
||||
'highlight' as annotation_type,
|
||||
mh.selection_text as display_text,
|
||||
mh.note_text as secondary_text,
|
||||
mh.color,
|
||||
mh.deleted_at,
|
||||
mh.created_at
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
mn.dedup_key,
|
||||
'note' as annotation_type,
|
||||
mn.content as display_text,
|
||||
NULL::text as secondary_text,
|
||||
NULL::text as color,
|
||||
mn.deleted_at,
|
||||
mn.created_at
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mb.id,
|
||||
mb.dedup_key,
|
||||
'bookmark' as annotation_type,
|
||||
mb.title as display_text,
|
||||
mb.notes as secondary_text,
|
||||
NULL::text as color,
|
||||
mb.deleted_at,
|
||||
mb.created_at
|
||||
FROM media_bookmarks mb
|
||||
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE
|
||||
ORDER BY deleted_at DESC;
|
||||
|
||||
-- name: RestoreMediaHighlightByID :execrows
|
||||
UPDATE media_highlights SET
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL,
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||
|
||||
-- name: RestoreMediaNoteByID :execrows
|
||||
UPDATE media_notes SET
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL,
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||
|
||||
-- name: RestoreMediaBookmarkByID :execrows
|
||||
UPDATE media_bookmarks SET
|
||||
deleted = FALSE,
|
||||
deleted_at = NULL,
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||
|
||||
-- Permanent removal from the history (distinct from the TTL-driven purge,
|
||||
-- which is maintenance). Scoped to the owning user and book.
|
||||
-- name: PurgeMediaHighlightByID :execrows
|
||||
DELETE FROM media_highlights
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||
|
||||
-- name: PurgeMediaNoteByID :execrows
|
||||
DELETE FROM media_notes
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||
|
||||
-- name: PurgeMediaBookmarkByID :execrows
|
||||
DELETE FROM media_bookmarks
|
||||
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||
|
||||
-- Refresh Tokens queries
|
||||
-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// DeletedAnnotationResponse is one entry of the deleted-annotation history
|
||||
// for a book (the book page's "recently deleted" list). Restoring returns the
|
||||
// row to the active set; purging removes it permanently.
|
||||
type DeletedAnnotationResponse struct {
|
||||
ID string `json:"id"`
|
||||
AnnotationType string `json:"annotation_type"`
|
||||
DisplayText string `json:"display_text"`
|
||||
SecondaryText string `json:"secondary_text,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// DeletedAnnotationsForBook builds the deleted-annotation history for a user
|
||||
// and book. Shared by the JSON API and the book page's server-rendered modal.
|
||||
func DeletedAnnotationsForBook(ctx context.Context, db *database.Queries, userID, mediaItemID pgtype.UUID) []DeletedAnnotationResponse {
|
||||
rows, err := db.ListDeletedAnnotationsForBook(ctx, database.ListDeletedAnnotationsForBookParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return []DeletedAnnotationResponse{}
|
||||
}
|
||||
|
||||
response := make([]DeletedAnnotationResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
entry := DeletedAnnotationResponse{
|
||||
ID: uuid.UUID(row.ID.Bytes).String(),
|
||||
AnnotationType: row.AnnotationType,
|
||||
DisplayText: row.DisplayText,
|
||||
SecondaryText: row.SecondaryText.String,
|
||||
Color: row.Color.String,
|
||||
}
|
||||
if row.DeletedAt.Valid {
|
||||
entry.DeletedAt = row.DeletedAt.Time
|
||||
}
|
||||
if row.CreatedAt.Valid {
|
||||
entry.CreatedAt = row.CreatedAt.Time
|
||||
}
|
||||
response = append(response, entry)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// GetDeletedAnnotations handles GET /api/media-items/:id/annotations/deleted
|
||||
func (mh *MediaHandler) GetDeletedAnnotations(c *echo.Context) error {
|
||||
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response := DeletedAnnotationsForBook(c.Request().Context(), mh.db, userUUID, mediaUUID)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"deleted_annotations": response,
|
||||
"total": len(response),
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreDeletedAnnotation handles POST /api/media-items/:id/annotations/:annotationId/restore
|
||||
// Body/query: annotation_type=highlight|note|bookmark
|
||||
func (mh *MediaHandler) RestoreDeletedAnnotation(c *echo.Context) error {
|
||||
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, true)
|
||||
if errResp != nil {
|
||||
return errResp
|
||||
}
|
||||
|
||||
if mh.annotationSvc == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
|
||||
}
|
||||
|
||||
restored, err := mh.annotationSvc.RestoreAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to restore annotation"})
|
||||
}
|
||||
if !restored {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"restored": true})
|
||||
}
|
||||
|
||||
// PurgeDeletedAnnotation handles DELETE /api/media-items/:id/annotations/:annotationId
|
||||
// Query: annotation_type=highlight|note|bookmark. Permanent — removes the
|
||||
// tombstoned row from the history.
|
||||
func (mh *MediaHandler) PurgeDeletedAnnotation(c *echo.Context) error {
|
||||
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, false)
|
||||
if errResp != nil {
|
||||
return errResp
|
||||
}
|
||||
|
||||
if mh.annotationSvc == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
|
||||
}
|
||||
|
||||
purged, err := mh.annotationSvc.PurgeAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to purge annotation"})
|
||||
}
|
||||
if !purged {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"purged": true})
|
||||
}
|
||||
|
||||
// parseUserAndMediaIDs extracts the authenticated user and the media item
|
||||
// from the route. A non-nil error has already been written as the response.
|
||||
func (mh *MediaHandler) parseUserAndMediaIDs(c *echo.Context) (pgtype.UUID, pgtype.UUID, error) {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
mediaID := c.Param("id")
|
||||
mediaIDUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
return pgtype.UUID{Bytes: userUUID, Valid: true}, pgtype.UUID{Bytes: mediaIDUUID, Valid: true}, nil
|
||||
}
|
||||
|
||||
// parseAnnotationHistoryRequest extracts user, media item, annotation ID, and
|
||||
// the annotation_type (from query param or JSON body — restore posts a body,
|
||||
// purge uses a query param). A non-nil error has already been written.
|
||||
func (mh *MediaHandler) parseAnnotationHistoryRequest(c *echo.Context, allowBody bool) (pgtype.UUID, pgtype.UUID, pgtype.UUID, string, error) {
|
||||
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", err
|
||||
}
|
||||
|
||||
annotationID := c.Param("annotationId")
|
||||
annotationUUID, err := uuid.Parse(annotationID)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid annotation id"})
|
||||
}
|
||||
|
||||
kind := c.QueryParam("annotation_type")
|
||||
if kind == "" && allowBody {
|
||||
var body struct {
|
||||
AnnotationType string `json:"annotation_type"`
|
||||
}
|
||||
if c.Bind(&body) == nil {
|
||||
kind = body.AnnotationType
|
||||
}
|
||||
}
|
||||
if !wsync.ValidAnnotationKind(kind) {
|
||||
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "annotation_type must be highlight, note, or bookmark"})
|
||||
}
|
||||
|
||||
return userUUID, mediaUUID, pgtype.UUID{Bytes: annotationUUID, Valid: true}, kind, nil
|
||||
}
|
||||
+479
-92
@@ -9,7 +9,10 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -47,7 +50,7 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) {
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
|
||||
if pos0 == "" || h.libraryService == nil {
|
||||
return "", ""
|
||||
}
|
||||
@@ -59,9 +62,89 @@ func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaIt
|
||||
if err != nil || epubPath == "" {
|
||||
return "", ""
|
||||
}
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
// The annotation's own text is the ideal anchor for the converter's
|
||||
// text-search path: clients (thin, underpowered) send only raw
|
||||
// locators, the server resolves them against the actual book.
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
return startLoc.CFI, endLoc.CFI
|
||||
endCFI := endLoc.CFI
|
||||
// The end conversion carries no context text, so unless it resolved
|
||||
// exactly it degenerates to a percentage fallback anchored at the
|
||||
// document start — useless as a range end. When the START resolved
|
||||
// exactly, derive the end from it: same node, character offset
|
||||
// advanced by the selection's UTF-16 length (the CFI offset unit).
|
||||
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" {
|
||||
endCFI = extendCFIByLength(startLoc.CFI, contextText)
|
||||
}
|
||||
return startLoc.CFI, endCFI
|
||||
}
|
||||
|
||||
// extendCFIByLength advances a point CFI's trailing character offset by the
|
||||
// UTF-16 length of text (EPUB CFI character offsets are UTF-16 code units).
|
||||
// Selections spanning multiple text nodes produce an out-of-range offset —
|
||||
// harmless: resolution clamps or fails, and consumers fall back to the start.
|
||||
func extendCFIByLength(cfi, text string) string {
|
||||
if cfi == "" || text == "" {
|
||||
return cfi
|
||||
}
|
||||
i := strings.LastIndex(cfi, ":")
|
||||
if i < 0 || !strings.HasSuffix(cfi, ")") {
|
||||
return cfi
|
||||
}
|
||||
off, err := strconv.Atoi(cfi[i+1 : len(cfi)-1])
|
||||
if err != nil {
|
||||
return cfi
|
||||
}
|
||||
utf16len := 0
|
||||
for _, r := range text {
|
||||
if r > 0xFFFF {
|
||||
utf16len += 2
|
||||
} else {
|
||||
utf16len++
|
||||
}
|
||||
}
|
||||
return cfi[:i+1] + strconv.Itoa(off+utf16len) + ")"
|
||||
}
|
||||
|
||||
// existingHighlightColor returns the stored color of the highlight matching
|
||||
// the dedup key ("" when none) so device echoes that carry no color never
|
||||
// clobber the web color.
|
||||
func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemID, userID pgtype.UUID, dedupKey string) string {
|
||||
if dedupKey == "" {
|
||||
return ""
|
||||
}
|
||||
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
UserID: userID,
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return existing.Color.String
|
||||
}
|
||||
|
||||
// deriveAnnotationPercentage computes a percentage for device-pushed
|
||||
// annotations when the client didn't send one (thin clients skip their own
|
||||
// per-annotation page lookups; arithmetic is only free on paging documents).
|
||||
func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 {
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
|
||||
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||
return float64(page) / float64(mediaItem.PageCount.Int32)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if wsync.IsCREXPointer(pos0) && h.libraryService != nil {
|
||||
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
|
||||
return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||
@@ -75,24 +158,35 @@ type KOReaderProgressRequest struct {
|
||||
}
|
||||
|
||||
type KOReaderBookProgress struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
// Device-side deletions, reported by dedup key. Keys refer to annotations
|
||||
// the device previously received from the server (or echoes of its own
|
||||
// pushes); the device only flags a deletion after observing the key in a
|
||||
// pull, so absence from these arrays is never interpreted as deletion.
|
||||
DeletedHighlights []KOReaderDeletedAnnotation `json:"deleted_highlights,omitempty"`
|
||||
DeletedBookmarks []KOReaderDeletedAnnotation `json:"deleted_bookmarks,omitempty"`
|
||||
}
|
||||
|
||||
// KOReaderDeletedAnnotation identifies a deleted annotation by dedup key.
|
||||
type KOReaderDeletedAnnotation struct {
|
||||
DedupKey string `json:"dedup_key"`
|
||||
}
|
||||
|
||||
type KOReaderDeviceInfo struct {
|
||||
@@ -100,53 +194,91 @@ type KOReaderDeviceInfo struct {
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
}
|
||||
|
||||
// FlexInt tolerates the loose types KOReader clients send for optional
|
||||
// numeric fields: JSON numbers, numeric strings ("30"), empty strings
|
||||
// (""), or non-numeric strings ("/body/..." xpointers in `page` for CRE
|
||||
// documents) — the latter decode to 0. Without this, a single annotation
|
||||
// carrying chapter:"" or page:"/body/..." failed the whole request bind
|
||||
// with a 400.
|
||||
type FlexInt int
|
||||
|
||||
func (f *FlexInt) UnmarshalJSON(b []byte) error {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "null" || s == `""` {
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
*f = FlexInt(n)
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
|
||||
inner := s[1 : len(s)-1]
|
||||
if n, err := strconv.Atoi(inner); err == nil {
|
||||
*f = FlexInt(n)
|
||||
return nil
|
||||
}
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
if fl, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
*f = FlexInt(int(fl))
|
||||
return nil
|
||||
}
|
||||
*f = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
type KOReaderBookmark struct {
|
||||
Chapter int `json:"chapter,omitempty"`
|
||||
Chapter FlexInt `json:"chapter,omitempty"`
|
||||
Datetime string `json:"datetime,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Pos0 string `json:"pos0,omitempty"`
|
||||
Pos1 string `json:"pos1,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
Page FlexInt `json:"page,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderHighlight struct {
|
||||
Chapter int `json:"chapter,omitempty"`
|
||||
Chapter FlexInt `json:"chapter,omitempty"`
|
||||
Datetime string `json:"datetime,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Pos0 string `json:"pos0,omitempty"`
|
||||
Pos1 string `json:"pos1,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
Page FlexInt `json:"page,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderNote struct {
|
||||
Chapter int `json:"chapter,omitempty"`
|
||||
Chapter FlexInt `json:"chapter,omitempty"`
|
||||
Datetime string `json:"datetime,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Pos0 string `json:"pos0,omitempty"`
|
||||
Pos1 string `json:"pos1,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
Page FlexInt `json:"page,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderSyncResponse struct {
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
}
|
||||
|
||||
type KOReaderBookSyncResult struct {
|
||||
@@ -174,20 +306,20 @@ type KOReaderMetadata struct {
|
||||
}
|
||||
|
||||
type KOReaderProgressData struct {
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderAnnotations struct {
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
|
||||
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
|
||||
}
|
||||
@@ -486,12 +618,16 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
for _, hl := range book.Highlights {
|
||||
startPos := hl.Pos0
|
||||
endPos := hl.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
// The highlight's own text anchors the conversion exactly.
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text)
|
||||
|
||||
pctStart := 0.0
|
||||
if hl.Percentage != nil {
|
||||
pctStart = *hl.Percentage
|
||||
}
|
||||
if pctStart == 0 {
|
||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page))
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": hl.Datetime,
|
||||
@@ -500,31 +636,55 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
"page": hl.Page,
|
||||
})
|
||||
|
||||
// Color semantics: devices render their own default and cannot
|
||||
// round-trip web colors. An echo carries NO color — preserve the
|
||||
// stored (web) color so round-trips never change it. A non-empty
|
||||
// color means the user edited the highlight on the device: map the
|
||||
// device color name and let it win.
|
||||
color := ""
|
||||
if hl.Color != "" {
|
||||
color = mapColorFromKOReader(hl.Color)
|
||||
}
|
||||
dedupKey := hl.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = wsync.ComputeDedupKey(hl.Text, epubcfiStart, startPos)
|
||||
}
|
||||
if color == "" {
|
||||
color = h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey)
|
||||
}
|
||||
if color == "" {
|
||||
color = "#ffd54f"
|
||||
}
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: hl.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: hl.Color,
|
||||
NoteText: hl.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: hl.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: color,
|
||||
NoteText: hl.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
}
|
||||
|
||||
for _, note := range book.Notes {
|
||||
startPos := note.Pos0
|
||||
endPos := note.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
|
||||
|
||||
pctStart := 0.0
|
||||
if note.Percentage != nil {
|
||||
pctStart = *note.Percentage
|
||||
}
|
||||
if pctStart == 0 {
|
||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page))
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
@@ -533,18 +693,25 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
dedupKey := note.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = wsync.ComputeDedupKey(note.Text, epubcfiStart, startPos)
|
||||
}
|
||||
|
||||
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: note.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
NoteText: note.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
SelectionText: note.Text,
|
||||
StartPosition: startPos,
|
||||
EndPosition: endPos,
|
||||
Color: h.existingHighlightColor(ctx, mediaItemID, userID, dedupKey),
|
||||
NoteText: note.Notes,
|
||||
PercentageStart: pctStart,
|
||||
EpubcfiStart: epubcfiStart,
|
||||
EpubcfiEnd: epubcfiEnd,
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,6 +729,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
dedupKey := bookmark.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = wsync.ComputeDedupKey(bookmark.Text, "", position)
|
||||
}
|
||||
|
||||
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
@@ -570,8 +742,33 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
ChapterNumber: int32(bookmark.Chapter),
|
||||
Source: "koreader",
|
||||
DeviceSyncData: deviceData,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
}
|
||||
|
||||
// Device-reported deletions: tombstone by dedup key. Tombstoned rows stay
|
||||
// in the history (restorable from the book page) and are echoed to other
|
||||
// devices as tombstones on their next pull. A device replay that pushes a
|
||||
// stale copy of the annotation cannot resurrect the tombstone (its save
|
||||
// carries no modification timestamp newer than the delete). Deletions run
|
||||
// after the upserts purely so a key present in both lists resolves to
|
||||
// "deleted" — the newer intent.
|
||||
for _, del := range book.DeletedHighlights {
|
||||
if del.DedupKey == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.annotationSvc.TombstoneHighlight(ctx, userID, mediaItemID, del.DedupKey, "koreader"); err != nil {
|
||||
log.Printf("KOReader: tombstone highlight by dedup key failed: %v", err)
|
||||
}
|
||||
}
|
||||
for _, del := range book.DeletedBookmarks {
|
||||
if del.DedupKey == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.annotationSvc.TombstoneBookmarkByDedupKey(ctx, userID, mediaItemID, del.DedupKey, "koreader"); err != nil {
|
||||
log.Printf("KOReader: tombstone bookmark by dedup key failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||
@@ -721,6 +918,43 @@ func int64PtrToPgInt8(i *int64) pgtype.Int8 {
|
||||
return pgtype.Int8{}
|
||||
}
|
||||
|
||||
type KOReaderResolveResponse struct {
|
||||
BookUUID string `json:"book_uuid"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveBook maps a file SHA-256 to the book's UUID without touching any
|
||||
// progress state. Devices need the UUID to pull metadata, but a freshly
|
||||
// downloaded book has none cached yet — the old way of learning it was to
|
||||
// push once, which transmitted the device's first-page position to the
|
||||
// server and manufactured a progress conflict for books already mid-read
|
||||
// from another source. This read-only lookup lets the client link (and
|
||||
// pull) without ever pushing bootstrap progress.
|
||||
func (h *KOReaderHandler) ResolveBook(c *echo.Context) error {
|
||||
sha256 := c.QueryParam("sha256")
|
||||
if sha256 == "" || len(sha256) != 64 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "sha256 query parameter is required (64 hex characters)",
|
||||
})
|
||||
}
|
||||
|
||||
mediaItem, _, err := h.bookResolver.ResolveBySHA256(c.Request().Context(), sha256)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "book not found",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, KOReaderResolveResponse{
|
||||
BookUUID: uuid.UUID(mediaItem.ID.Bytes).String(),
|
||||
SHA256: sha256,
|
||||
Title: mediaItem.Title,
|
||||
Author: mediaItem.Author.String,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
@@ -809,34 +1043,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
|
||||
for _, ann := range annotations {
|
||||
if ann.AnnotationType == "highlight" {
|
||||
pos0 := ann.StartPosition.String
|
||||
pos1 := ann.EndPosition.String
|
||||
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
|
||||
pos0 = converted
|
||||
}
|
||||
// Selection text doubles as the converter's text-search context.
|
||||
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, ann.SelectionText)
|
||||
pos1 := h.koreaderPos0(c, mediaItem, ann.EndPosition.String, ann.EpubcfiEnd.String, ann.SelectionText)
|
||||
if pos0 == "" {
|
||||
// Nothing the device could place — serving a locator it can't
|
||||
// resolve would create junk bookmarks that re-push as
|
||||
// duplicates, so skip instead.
|
||||
log.Printf("Bookhoard: GetMetadata skip highlight %s (no resolvable pos0)", ann.ID)
|
||||
continue
|
||||
}
|
||||
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
|
||||
pos1 = converted
|
||||
}
|
||||
// Old web highlights carry no end anchor, and converted range
|
||||
// CFIs resolve to their start — either way pos1 collapses onto
|
||||
// pos0 and the device paints a zero-width highlight. Derive the
|
||||
// end by advancing the start's character offset by the length
|
||||
// of the selected text.
|
||||
if pos1 == "" || pos1 == pos0 {
|
||||
pos1 = extendXPointerByLength(pos0, ann.SelectionText)
|
||||
}
|
||||
highlight := KOReaderHighlight{
|
||||
Text: ann.SelectionText,
|
||||
Pos0: pos0,
|
||||
Pos1: pos1,
|
||||
Color: ann.Color.String,
|
||||
Text: ann.SelectionText,
|
||||
Pos0: pos0,
|
||||
Pos1: pos1,
|
||||
// Web colors flow to the device, mapped to KOReader's named
|
||||
// palette. Round-trip safety: the device suppresses the color
|
||||
// when echoing un-edited applied entries (a pink→purple
|
||||
// palette mismatch must not rewrite the stored hex), and an
|
||||
// actual device edit pushes its color, which wins.
|
||||
Color: mapColorToKOReader(ann.Color.String),
|
||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||
DedupKey: ann.DedupKey.String,
|
||||
}
|
||||
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
||||
highlight.Notes = ann.NoteText.String
|
||||
}
|
||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
||||
} else if ann.AnnotationType == "note" {
|
||||
pos0 := h.koreaderPos0(c, mediaItem, ann.StartPosition.String, ann.EpubcfiStart.String, "")
|
||||
if pos0 == "" {
|
||||
log.Printf("Bookhoard: GetMetadata skip note %s (no resolvable pos0)", ann.ID)
|
||||
continue
|
||||
}
|
||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||
Text: ann.SelectionText,
|
||||
Pos0: ann.StartPosition.String,
|
||||
Pos0: pos0,
|
||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||
DedupKey: ann.DedupKey.String,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -846,21 +1098,23 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
UserID: pgUserID,
|
||||
})
|
||||
for _, bm := range bookmarks {
|
||||
pos0 := bm.Position.String
|
||||
if pos0 == "" && bm.CfiPosition.Valid {
|
||||
pos0 = bm.CfiPosition.String
|
||||
pos0 := h.koreaderPos0(c, mediaItem, bm.Position.String, bm.CfiPosition.String, "")
|
||||
if pos0 == "" {
|
||||
log.Printf("Bookhoard: GetMetadata skip bookmark %s (no resolvable pos0)", bm.ID)
|
||||
continue
|
||||
}
|
||||
koreaderBookmark := KOReaderBookmark{
|
||||
Text: bm.Title,
|
||||
Pos0: pos0,
|
||||
Pos1: pos0,
|
||||
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
||||
DedupKey: bm.DedupKey.String,
|
||||
}
|
||||
if bm.Notes.Valid && bm.Notes.String != "" {
|
||||
koreaderBookmark.Notes = bm.Notes.String
|
||||
}
|
||||
if bm.ChapterNumber.Valid {
|
||||
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
|
||||
koreaderBookmark.Chapter = FlexInt(bm.ChapterNumber.Int32)
|
||||
}
|
||||
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||
}
|
||||
@@ -880,6 +1134,14 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
dd = map[string]interface{}{}
|
||||
}
|
||||
dd["dedup_key"] = ts.DedupKey.String
|
||||
// KOReader deletes by matching pos0. Device-pushed annotations carry
|
||||
// it in device_sync_data; web-created ones don't (their locator is
|
||||
// converted at serve time), so resolve it from the stored columns.
|
||||
if dd["pos0"] == nil || dd["pos0"] == "" {
|
||||
if pos0 := h.koreaderPos0(c, mediaItem, ts.StartPosition.String, ts.EpubcfiStart.String, ""); pos0 != "" {
|
||||
dd["pos0"] = pos0
|
||||
}
|
||||
}
|
||||
if ts.AnnotationType == "highlight" {
|
||||
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
||||
} else if ts.AnnotationType == "bookmark" {
|
||||
@@ -939,7 +1201,7 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string {
|
||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) string {
|
||||
if h.libraryService == nil || epubcfi == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -947,13 +1209,138 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
|
||||
if err != nil || epubPath == "" {
|
||||
return ""
|
||||
}
|
||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||
if loc.Position != "" && loc.Position != epubcfi {
|
||||
return loc.Position
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pdfRectAnchor is the JSON locator the web reader stores in epubcfi_start
|
||||
// for PDF text highlights (page-fraction rects; page index is 0-based).
|
||||
type pdfRectAnchor struct {
|
||||
V int `json:"v"`
|
||||
Page int `json:"page"`
|
||||
Rects [][]float64 `json:"rects"`
|
||||
}
|
||||
|
||||
// koreaderPos0 resolves a device-native KOReader pos0 from an annotation's
|
||||
// stored locators, whatever the source. Resolution order:
|
||||
//
|
||||
// extendXPointerByLength advances a CRE xpointer's trailing text-node
|
||||
// character offset by the rune length of text, so a highlight with only a
|
||||
// start anchor still gets a plausible (non-collapsed) end for drawing.
|
||||
// Overshooting the node just clamps on the device.
|
||||
func extendXPointerByLength(xp, text string) string {
|
||||
if xp == "" || text == "" {
|
||||
return xp
|
||||
}
|
||||
i := strings.LastIndex(xp, ".")
|
||||
if i < 0 {
|
||||
return xp
|
||||
}
|
||||
off, err := strconv.Atoi(xp[i+1:])
|
||||
if err != nil {
|
||||
return xp
|
||||
}
|
||||
return xp[:i+1] + strconv.Itoa(off+utf8.RuneCountInString(text))
|
||||
}
|
||||
|
||||
// KOReader paints highlight colors from a fixed set of names
|
||||
// (Blitbuffer.HIGHLIGHT_COLORS); the web reader uses hex swatches. Map at
|
||||
// the boundary so each side always receives something it can render;
|
||||
// unmappable values fall back to each side's default (yellow).
|
||||
var koreaderColorFromName = map[string]string{
|
||||
"yellow": "#ffd54f",
|
||||
"orange": "#ffd54f",
|
||||
"green": "#a5d6a7",
|
||||
"olive": "#a5d6a7",
|
||||
"cyan": "#90caf9",
|
||||
"blue": "#90caf9",
|
||||
"purple": "#ce93d8",
|
||||
"red": "#f48fb1",
|
||||
}
|
||||
|
||||
// mapColorFromKOReader normalizes a device color name to a web hex
|
||||
// swatch (default yellow) when ingesting device pushes.
|
||||
func mapColorFromKOReader(name string) string {
|
||||
if hex, ok := koreaderColorFromName[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||
return hex
|
||||
}
|
||||
return "#ffd54f"
|
||||
}
|
||||
|
||||
var koreaderColorFromHex = map[string]string{
|
||||
"#ffd54f": "yellow",
|
||||
"#a5d6a7": "green",
|
||||
"#90caf9": "blue",
|
||||
"#ce93d8": "purple",
|
||||
"#f48fb1": "purple",
|
||||
}
|
||||
|
||||
// mapColorToKOReader normalizes a web hex swatch to the nearest KOReader
|
||||
// color name (default yellow) when serving to devices. Pink maps to purple
|
||||
// (the palette's closest); round-trip drift is prevented on the device by
|
||||
// suppressing echo colors for un-edited applied entries.
|
||||
func mapColorToKOReader(hex string) string {
|
||||
if name, ok := koreaderColorFromHex[strings.ToLower(strings.TrimSpace(hex))]; ok {
|
||||
return name
|
||||
}
|
||||
return "yellow"
|
||||
}
|
||||
|
||||
// 1. A device-native CRE xpointer ("/body/...") in startPosition wins —
|
||||
// round-trip identical for KOReader-pushed annotations (converting the
|
||||
// stored CFI instead could drift and duplicate on the device).
|
||||
// 2. The web reader's PDF JSON anchor → bare page number (KOReader paging
|
||||
// documents use the page number as pos0).
|
||||
// 3. A stored EPUB CFI (epubcfi_start, or startPosition without the
|
||||
// reader's "cfi:" prefix) → converted to a CRE xpointer, with
|
||||
// contextText (the selection text) enabling the text-search fallback.
|
||||
// 4. A "page:N" or bare-numeric position → the bare number.
|
||||
//
|
||||
// Returns "" when nothing usable exists; callers skip such annotations so
|
||||
// devices never receive locators they cannot place.
|
||||
func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.MediaItems, startPosition, epubcfi, contextText string) string {
|
||||
if wsync.IsCREXPointer(startPosition) {
|
||||
return startPosition
|
||||
}
|
||||
if strings.HasPrefix(epubcfi, "{") {
|
||||
var anchor pdfRectAnchor
|
||||
if json.Unmarshal([]byte(epubcfi), &anchor) == nil && anchor.Page >= 0 {
|
||||
return strconv.Itoa(anchor.Page)
|
||||
}
|
||||
}
|
||||
cfi := epubcfi
|
||||
if cfi == "" && strings.HasPrefix(startPosition, "cfi:") {
|
||||
cfi = strings.TrimPrefix(startPosition, "cfi:")
|
||||
}
|
||||
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
|
||||
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" {
|
||||
return converted
|
||||
}
|
||||
// Conversion failed; fall through so numeric positions still work.
|
||||
if wsync.IsCREXPointer(cfi) {
|
||||
return cfi
|
||||
}
|
||||
}
|
||||
if p := strings.TrimPrefix(startPosition, "page:"); p != "" && parsePageInt(p) >= 0 {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parsePageInt(s string) int64 {
|
||||
var n int64
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return -1
|
||||
}
|
||||
n = n*10 + int64(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
@@ -1195,13 +1582,13 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
endPos = startPos
|
||||
}
|
||||
|
||||
color := "#ffff00"
|
||||
color := "#ffd54f"
|
||||
if highlight.Color != "" {
|
||||
color = highlight.Color
|
||||
color = mapColorFromKOReader(highlight.Color)
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil {
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text)
|
||||
|
||||
pctStart := 0.0
|
||||
if highlight.Percentage != nil {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// The device pushes deletions as dedup-key arrays on the progress request.
|
||||
// Verify the wire shape the plugin sends (lua json.encode of
|
||||
// { deleted_highlights = { { dedup_key = "..." } } }) binds correctly.
|
||||
func TestKOReaderProgressRequest_DeletedAnnotationsBinding(t *testing.T) {
|
||||
payload := `{
|
||||
"books": [{
|
||||
"sha256": "d1b1c6123d6206017b40798744ed994f00803b97d22ce51bea32e95e1ce7a164",
|
||||
"title": "1984",
|
||||
"percentage": 0.42,
|
||||
"deleted_highlights": [
|
||||
{ "dedup_key": "abc123" },
|
||||
{ "dedup_key": "def456" }
|
||||
],
|
||||
"deleted_bookmarks": [
|
||||
{ "dedup_key": "789xyz" }
|
||||
]
|
||||
}]
|
||||
}`
|
||||
|
||||
var req KOReaderProgressRequest
|
||||
err := json.Unmarshal([]byte(payload), &req)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, req.Books, 1)
|
||||
|
||||
book := req.Books[0]
|
||||
assert.Len(t, book.DeletedHighlights, 2)
|
||||
assert.Equal(t, "abc123", book.DeletedHighlights[0].DedupKey)
|
||||
assert.Equal(t, "def456", book.DeletedHighlights[1].DedupKey)
|
||||
assert.Len(t, book.DeletedBookmarks, 1)
|
||||
assert.Equal(t, "789xyz", book.DeletedBookmarks[0].DedupKey)
|
||||
}
|
||||
|
||||
// A request without the arrays (older plugins) must bind with them empty —
|
||||
// deletion propagation is strictly opt-in per push.
|
||||
func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
|
||||
payload := `{"books": [{"sha256": "x", "title": "t", "percentage": 0.1}]}`
|
||||
|
||||
var req KOReaderProgressRequest
|
||||
err := json.Unmarshal([]byte(payload), &req)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, req.Books[0].DeletedHighlights)
|
||||
assert.Empty(t, req.Books[0].DeletedBookmarks)
|
||||
}
|
||||
@@ -18,4 +18,8 @@ type MediaDetail struct {
|
||||
// Computed counts
|
||||
NotesCount int `json:"notes_count"`
|
||||
HighlightsCount int `json:"highlights_count"`
|
||||
|
||||
// Deleted-annotation history (tombstoned rows, newest first) — the book
|
||||
// page's "recently deleted" list with restore/permanent-delete actions.
|
||||
DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"`
|
||||
}
|
||||
|
||||
@@ -1329,13 +1329,14 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
// Assemble response (no field duplication!)
|
||||
detail := handlers.MediaDetail{
|
||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
ActiveConflict: activeConflict,
|
||||
NotesCount: len(notes),
|
||||
HighlightsCount: len(highlights),
|
||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
ActiveConflict: activeConflict,
|
||||
NotesCount: len(notes),
|
||||
HighlightsCount: len(highlights),
|
||||
DeletedAnnotations: handlers.DeletedAnnotationsForBook(c.Request().Context(), cfg.Queries, pgUserID, pgMediaUUID),
|
||||
}
|
||||
|
||||
// Render template
|
||||
|
||||
@@ -47,6 +47,13 @@ func registerMediaRoutes(cfg *Config) {
|
||||
protected.PUT("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.UpdateMediaBookmark)
|
||||
protected.DELETE("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.DeleteMediaBookmark)
|
||||
|
||||
// Deleted-annotation history (all authenticated users): tombstoned
|
||||
// highlights/notes/bookmarks restorable or permanently removable from the
|
||||
// book page's "recently deleted" list.
|
||||
protected.GET("/media-items/:id/annotations/deleted", cfg.MediaHandler.GetDeletedAnnotations)
|
||||
protected.POST("/media-items/:id/annotations/:annotationId/restore", cfg.MediaHandler.RestoreDeletedAnnotation)
|
||||
protected.DELETE("/media-items/:id/annotations/:annotationId", cfg.MediaHandler.PurgeDeletedAnnotation)
|
||||
|
||||
// Admin-only media routes
|
||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||
|
||||
@@ -24,6 +24,7 @@ func registerSyncRoutes(cfg *Config) {
|
||||
// KOReader sync routes (device authentication required)
|
||||
koreaderSync := e.Group("/api/sync/koreader")
|
||||
koreaderSync.POST("/progress", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncProgress))
|
||||
koreaderSync.GET("/resolve", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.ResolveBook))
|
||||
koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata))
|
||||
koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
|
||||
koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks))
|
||||
|
||||
+168
-57
@@ -23,8 +23,8 @@ import (
|
||||
const TombstoneTTL = 30 * 24 * time.Hour
|
||||
|
||||
type AnnotationService struct {
|
||||
db *database.Queries
|
||||
connMgr *ConnectionManager
|
||||
db *database.Queries
|
||||
connMgr *ConnectionManager
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ type SaveHighlightRequest struct {
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
// DedupKey overrides the computed key when the client echoes back an
|
||||
// annotation it received from us (device echoes carry device-native
|
||||
// locators, so the computed key would never match the original row and
|
||||
// every pull→push cycle would mint a duplicate).
|
||||
DedupKey string
|
||||
}
|
||||
|
||||
type SaveHighlightResult struct {
|
||||
@@ -84,7 +89,10 @@ type SaveHighlightResult struct {
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
||||
dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||
dedupKey := req.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||
}
|
||||
|
||||
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
@@ -124,22 +132,22 @@ func (s *AnnotationService) createHighlight(
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create highlight: %w", err)
|
||||
@@ -179,20 +187,20 @@ func (s *AnnotationService) applyLWW(
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
ID: existing.ID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update highlight: %w", err)
|
||||
@@ -260,6 +268,99 @@ func (s *AnnotationService) TombstoneHighlightByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// TombstoneBookmarkByDedupKey soft-deletes a bookmark by its dedup key — the
|
||||
// device-sync counterpart of TombstoneHighlight. Devices report deletions by
|
||||
// dedup key (they have no row IDs), so this keeps bookmark delete propagation
|
||||
// symmetric with highlights.
|
||||
func (s *AnnotationService) TombstoneBookmarkByDedupKey(
|
||||
ctx context.Context,
|
||||
userID, mediaItemID pgtype.UUID,
|
||||
dedupKey string,
|
||||
source string,
|
||||
) error {
|
||||
if dedupKey == "" {
|
||||
return nil
|
||||
}
|
||||
err := s.db.TombstoneMediaBookmarkByDedupKey(ctx, database.TombstoneMediaBookmarkByDedupKeyParams{
|
||||
UserID: userID,
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone bookmark: %w", err)
|
||||
}
|
||||
s.broadcast(pgtype.UUID{}, userID, mediaItemID, "bookmark_delete", source)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidAnnotationKind reports whether kind is one of the annotation types
|
||||
// accepted by the history restore/purge endpoints.
|
||||
func ValidAnnotationKind(kind string) bool {
|
||||
return kind == "highlight" || kind == "note" || kind == "bookmark"
|
||||
}
|
||||
|
||||
// RestoreAnnotationByID clears the tombstone on a deleted annotation,
|
||||
// returning it to the active set. The row itself was never removed, so
|
||||
// restoration is lossless. Returns false when no matching deleted annotation
|
||||
// exists (wrong owner, wrong book, or not actually deleted).
|
||||
func (s *AnnotationService) RestoreAnnotationByID(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
userID, mediaItemID, annotationID pgtype.UUID,
|
||||
) (bool, error) {
|
||||
var rows int64
|
||||
var err error
|
||||
switch kind {
|
||||
case "highlight":
|
||||
rows, err = s.db.RestoreMediaHighlightByID(ctx, database.RestoreMediaHighlightByIDParams{
|
||||
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
||||
case "note":
|
||||
rows, err = s.db.RestoreMediaNoteByID(ctx, database.RestoreMediaNoteByIDParams{
|
||||
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
||||
case "bookmark":
|
||||
rows, err = s.db.RestoreMediaBookmarkByID(ctx, database.RestoreMediaBookmarkByIDParams{
|
||||
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
||||
default:
|
||||
return false, fmt.Errorf("unknown annotation kind: %s", kind)
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("restore %s: %w", kind, err)
|
||||
}
|
||||
if rows > 0 {
|
||||
s.broadcast(annotationID, userID, mediaItemID, kind, "web")
|
||||
}
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// PurgeAnnotationByID permanently deletes an already-tombstoned annotation
|
||||
// from the history. Unlike a tombstone this is irreversible; the TTL-driven
|
||||
// maintenance sweep does the same thing to old tombstones eventually.
|
||||
func (s *AnnotationService) PurgeAnnotationByID(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
userID, mediaItemID, annotationID pgtype.UUID,
|
||||
) (bool, error) {
|
||||
var rows int64
|
||||
var err error
|
||||
switch kind {
|
||||
case "highlight":
|
||||
rows, err = s.db.PurgeMediaHighlightByID(ctx, database.PurgeMediaHighlightByIDParams{
|
||||
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
||||
case "note":
|
||||
rows, err = s.db.PurgeMediaNoteByID(ctx, database.PurgeMediaNoteByIDParams{
|
||||
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
||||
case "bookmark":
|
||||
rows, err = s.db.PurgeMediaBookmarkByID(ctx, database.PurgeMediaBookmarkByIDParams{
|
||||
ID: annotationID, UserID: userID, MediaItemID: mediaItemID})
|
||||
default:
|
||||
return false, fmt.Errorf("unknown annotation kind: %s", kind)
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("purge %s: %w", kind, err)
|
||||
}
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-s.tombstoneTTL()), Valid: true}
|
||||
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
|
||||
@@ -335,6 +436,7 @@ type SaveNoteRequest struct {
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData []byte
|
||||
DedupKey string // overrides the computed key for device echoes
|
||||
}
|
||||
|
||||
type SaveNoteResult struct {
|
||||
@@ -348,7 +450,10 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (
|
||||
return nil, errors.New("invalid user_id or media_item_id")
|
||||
}
|
||||
|
||||
dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
|
||||
dedupKey := req.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
|
||||
}
|
||||
|
||||
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
@@ -485,6 +590,9 @@ type SaveBookmarkRequest struct {
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
// DedupKey overrides the computed key for device echoes (see
|
||||
// SaveHighlightRequest).
|
||||
DedupKey string
|
||||
}
|
||||
|
||||
type SaveBookmarkResult struct {
|
||||
@@ -494,7 +602,10 @@ type SaveBookmarkResult struct {
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
|
||||
dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
|
||||
dedupKey := req.DedupKey
|
||||
if dedupKey == "" {
|
||||
dedupKey = ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
|
||||
}
|
||||
|
||||
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
@@ -530,21 +641,21 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||
@@ -573,19 +684,19 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
ID: existing.ID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update bookmark: %w", err)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
@@ -19,6 +20,9 @@ import (
|
||||
type CFIConverter struct {
|
||||
epubPath string
|
||||
cache *spineCache
|
||||
// mu guards the lazily-built spine/doc caches: converter instances are
|
||||
// shared across concurrent requests via the package cache in locators.go.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type spineItem struct {
|
||||
@@ -37,6 +41,8 @@ func NewCFIConverter(epubPath string) *CFIConverter {
|
||||
}
|
||||
|
||||
func (c *CFIConverter) loadSpine() (*spineCache, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cache != nil {
|
||||
return c.cache, nil
|
||||
}
|
||||
@@ -94,6 +100,8 @@ func (c *CFIConverter) getContentDoc(fragmentIndex int) (*html.Node, string, err
|
||||
item := spine.items[spineIndex]
|
||||
href := item.href
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if cached, ok := spine.docCache[href]; ok {
|
||||
return cached, href, nil
|
||||
}
|
||||
@@ -243,6 +251,46 @@ type ConversionResult struct {
|
||||
Precision string
|
||||
}
|
||||
|
||||
// SectionPercentage derives an approximate book-wide percentage for a CRE
|
||||
// xpointer from the char distribution across the spine: the midpoint of the
|
||||
// document it points into. Precision is per-section, which is what
|
||||
// percentage_start is used for (ordering/filtering) — and it lets thin
|
||||
// clients skip their own per-annotation page lookups entirely.
|
||||
func (c *CFIConverter) SectionPercentage(xpointer string) float64 {
|
||||
xp, err := ParseCREXPointer(xpointer)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
spine, err := c.loadSpine()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
total := 0
|
||||
charCounts := make([]int, len(spine.items))
|
||||
for i := range spine.items {
|
||||
doc, _, docErr := c.getContentDoc(i + 1)
|
||||
if docErr != nil {
|
||||
continue
|
||||
}
|
||||
if b := findBody(doc); b != nil {
|
||||
charCounts[i] = countTextChars(b)
|
||||
total += charCounts[i]
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
idx := xp.FragmentIndex - 1
|
||||
if idx < 0 || idx >= len(spine.items) {
|
||||
return 0
|
||||
}
|
||||
before := 0
|
||||
for i := 0; i < idx; i++ {
|
||||
before += charCounts[i]
|
||||
}
|
||||
return (float64(before) + float64(charCounts[idx])/2) / float64(total)
|
||||
}
|
||||
|
||||
func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) {
|
||||
if IsCREFragmentID(xpointer) {
|
||||
return c.convertFragmentID(xpointer, storedPercentage)
|
||||
@@ -884,8 +932,8 @@ func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
|
||||
}
|
||||
|
||||
type opfContainer struct {
|
||||
XMLName xml.Name `xml:"container"`
|
||||
RootFiles []opfRoot `xml:"rootfiles>rootfile"`
|
||||
XMLName xml.Name `xml:"container"`
|
||||
RootFiles []opfRoot `xml:"rootfiles>rootfile"`
|
||||
}
|
||||
|
||||
type opfRoot struct {
|
||||
@@ -906,8 +954,8 @@ func extractOPFPath(data []byte) (string, error) {
|
||||
}
|
||||
|
||||
type xmlPackage struct {
|
||||
XMLName xml.Name `xml:"package"`
|
||||
Spine xmlSpine `xml:"spine"`
|
||||
XMLName xml.Name `xml:"package"`
|
||||
Spine xmlSpine `xml:"spine"`
|
||||
Manifest xmlManifest `xml:"manifest"`
|
||||
}
|
||||
|
||||
@@ -1036,9 +1084,9 @@ func preprocessXHTML(input string) string {
|
||||
}
|
||||
|
||||
type cfiStep struct {
|
||||
Index int
|
||||
ID string
|
||||
Offset int
|
||||
Index int
|
||||
ID string
|
||||
Offset int
|
||||
HasOffset bool
|
||||
}
|
||||
|
||||
|
||||
+195
-123
@@ -1,6 +1,8 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -9,7 +11,7 @@ import (
|
||||
|
||||
func TestParseCREXPointer(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
input string
|
||||
wantFrag int
|
||||
wantPath int
|
||||
wantChar int
|
||||
@@ -72,9 +74,9 @@ func TestIsCREFragmentID(t *testing.T) {
|
||||
|
||||
func TestParseCREFragmentID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantSpine int
|
||||
wantAnchor string
|
||||
input string
|
||||
wantSpine int
|
||||
wantAnchor string
|
||||
}{
|
||||
{"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"},
|
||||
{"#_doc_fragment_0_", 0, ""},
|
||||
@@ -116,57 +118,224 @@ func TestIsStandardEPUBCFI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvert1984(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
// writeTestEPUB builds a minimal, deterministic EPUB in a temp dir so the
|
||||
// conversion tests exercise the real zip→OPF→spine→document pipeline
|
||||
// without depending on books in a particular machine's uploads/ tree.
|
||||
//
|
||||
// Spine: doc1..doc6. doc2 carries the Dashwood sentence used for exact and
|
||||
// text-search anchoring; doc6 has an id anchor for fragment-ID conversion.
|
||||
func writeTestEPUB(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
xp := "/body/DocFragment[2]/body/div/p[5]/text().500"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.01, "")
|
||||
type spineDoc struct {
|
||||
name string
|
||||
body string
|
||||
}
|
||||
docs := []spineDoc{
|
||||
{"doc1.xhtml", "<body><div><p>Chapter one opening page.</p></div></body>"},
|
||||
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p></div></body>"},
|
||||
{"doc3.xhtml", "<body><div><p>Chapter three contents.</p></div></body>"},
|
||||
{"doc4.xhtml", "<body><div><p>Chapter four contents.</p></div></body>"},
|
||||
{"doc5.xhtml", "<body><div><p>Chapter five contents.</p></div></body>"},
|
||||
{"doc6.xhtml", "<body><div><p id=\"link2HCH0002\">He was neither fit to be a husband nor a father.</p></div></body>"},
|
||||
}
|
||||
|
||||
containerXML := `<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`
|
||||
|
||||
manifest := ""
|
||||
spineRefs := ""
|
||||
for _, d := range docs {
|
||||
id := d.name[:len(d.name)-len(".xhtml")]
|
||||
manifest += " <item id=\"" + id + "\" href=\"" + d.name + "\" media-type=\"application/xhtml+xml\"/>\n"
|
||||
spineRefs += " <itemref idref=\"" + id + "\"/>\n"
|
||||
}
|
||||
opf := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="uid">test-bookhoard-fixture</dc:identifier>
|
||||
<dc:title>Fixture</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
` + manifest + ` </manifest>
|
||||
<spine>
|
||||
` + spineRefs + ` </spine>
|
||||
</package>`
|
||||
|
||||
path := t.TempDir() + "/fixture.epub"
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
zw := zip.NewWriter(f)
|
||||
write := func(name, content string) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("META-INF/container.xml", containerXML)
|
||||
write("OEBPS/content.opf", opf)
|
||||
for _, d := range docs {
|
||||
write("OEBPS/"+d.name, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">"+d.body+"</html>\n")
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const fixtureSentence = "The family of Dashwood had long been settled in Sussex."
|
||||
|
||||
func TestConvertXPointerToCFI(t *testing.T) {
|
||||
c := NewCFIConverter(writeTestEPUB(t))
|
||||
|
||||
xp := "/body/DocFragment[2]/body/div[1]/p[1]/text().10"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.05, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("Input: %s", xp)
|
||||
t.Logf("EPUBCFI: %s", result.EPUBCFI)
|
||||
t.Logf("Href: %s", result.Href)
|
||||
t.Logf("Precision: %s", result.Precision)
|
||||
t.Logf("Percentage: %.4f", result.Percentage)
|
||||
|
||||
if result.Precision == "percentage" {
|
||||
t.Error("expected better than percentage precision")
|
||||
}
|
||||
if result.EPUBCFI == "" {
|
||||
t.Error("expected non-empty epubcfi")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCrimeAndPunishmentFragmentID(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
func TestConvertFragmentID(t *testing.T) {
|
||||
c := NewCFIConverter(writeTestEPUB(t))
|
||||
|
||||
xp := "#_doc_fragment_5_ link2HCH0002"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.0303, "")
|
||||
frag := "#_doc_fragment_5_ link2HCH0002"
|
||||
result, err := c.ConvertCREToStandard(frag, 0.9, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("Input: %s", xp)
|
||||
t.Logf("EPUBCFI: %s", result.EPUBCFI)
|
||||
t.Logf("Input: %s", frag)
|
||||
t.Logf("Href: %s", result.Href)
|
||||
t.Logf("Precision: %s", result.Precision)
|
||||
t.Logf("Percentage: %.4f", result.Percentage)
|
||||
|
||||
if result.Precision == "percentage" {
|
||||
t.Error("expected better than percentage precision")
|
||||
if result.Precision != "element" {
|
||||
t.Errorf("expected element precision, got %s", result.Precision)
|
||||
}
|
||||
if result.Href == "" {
|
||||
t.Error("expected non-empty href")
|
||||
}
|
||||
if result.Precision != "element" {
|
||||
t.Errorf("expected element precision, got %s", result.Precision)
|
||||
if !strings.Contains(result.Href, "doc6.xhtml#link2HCH0002") {
|
||||
t.Errorf("expected doc6.xhtml#link2HCH0002 href, got %s", result.Href)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripXPointer(t *testing.T) {
|
||||
c := NewCFIConverter(writeTestEPUB(t))
|
||||
|
||||
originalXP := "/body/DocFragment[2]/body/div[1]/p[1]/text().10"
|
||||
forward, err := c.ConvertCREToStandard(originalXP, 0.05, "")
|
||||
if err != nil {
|
||||
t.Fatalf("forward conversion error: %v", err)
|
||||
}
|
||||
if forward.EPUBCFI == "" {
|
||||
t.Fatal("forward conversion produced empty epubcfi")
|
||||
}
|
||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Fatal("reverse conversion produced empty XPointer")
|
||||
}
|
||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripWithContextText(t *testing.T) {
|
||||
c := NewCFIConverter(writeTestEPUB(t))
|
||||
|
||||
originalXP := "/body/DocFragment[2]/body/div[1]/p[2]/text().3"
|
||||
forward, err := c.ConvertCREToStandard(originalXP, 0.06, fixtureSentence)
|
||||
if err != nil {
|
||||
t.Fatalf("forward conversion error: %v", err)
|
||||
}
|
||||
if forward.EPUBCFI == "" {
|
||||
t.Fatal("forward conversion produced empty epubcfi")
|
||||
}
|
||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, fixtureSentence)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Fatal("reverse conversion produced empty XPointer")
|
||||
}
|
||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseTextSearchFallback(t *testing.T) {
|
||||
c := NewCFIConverter(writeTestEPUB(t))
|
||||
|
||||
// Unresolvable steps in a CFI that still parses to spine doc2
|
||||
// (spine index 1): the text search must anchor on the sentence.
|
||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.05, fixtureSentence)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
t.Logf("Text search fallback XPointer: %s", reverse.XPointer)
|
||||
t.Logf("Precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision from text search, got %s", reverse.Precision)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Error("expected non-empty XPointer from text search")
|
||||
}
|
||||
if !strings.Contains(reverse.XPointer, "DocFragment[2]") {
|
||||
t.Errorf("expected fallback into DocFragment[2], got %s", reverse.XPointer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReversePercentageFallback(t *testing.T) {
|
||||
c := NewCFIConverter(writeTestEPUB(t))
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.5, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
t.Logf("Percentage fallback precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "percentage" {
|
||||
t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer)
|
||||
}
|
||||
if reverse.XPointer != "" {
|
||||
t.Error("expected empty XPointer for percentage fallback")
|
||||
}
|
||||
}
|
||||
func TestParseEPUBCFI(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantSpine int
|
||||
wantSteps int
|
||||
input string
|
||||
wantSpine int
|
||||
wantSteps int
|
||||
}{
|
||||
{"epubcfi(/6/12!/4/2/90/1:7)", 5, 4},
|
||||
{"epubcfi(/6/4!/4/2/1:0)", 1, 3},
|
||||
@@ -213,103 +382,6 @@ func TestParseEPUBCFIInvalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip1984(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
originalXP := "/body/DocFragment[2]/body/div/p[5]/text().500"
|
||||
forward, err := c.ConvertCREToStandard(originalXP, 0.01, "")
|
||||
if err != nil {
|
||||
t.Fatalf("forward conversion error: %v", err)
|
||||
}
|
||||
if forward.EPUBCFI == "" {
|
||||
t.Fatal("forward conversion produced empty epubcfi")
|
||||
}
|
||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Fatal("reverse conversion produced empty XPointer")
|
||||
}
|
||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
||||
t.Logf("Reverse precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripCP(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
originalXP := "/body/DocFragment[6]/body/div/p[47]/text().2399"
|
||||
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
|
||||
forward, err := c.ConvertCREToStandard(originalXP, 0.0579, contextText)
|
||||
if err != nil {
|
||||
t.Fatalf("forward conversion error: %v", err)
|
||||
}
|
||||
if forward.EPUBCFI == "" {
|
||||
t.Fatal("forward conversion produced empty epubcfi")
|
||||
}
|
||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, contextText)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Fatal("reverse conversion produced empty XPointer")
|
||||
}
|
||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
||||
t.Logf("Reverse precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseTextSearchFallback(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
|
||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.0579, contextText)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
t.Logf("Text search fallback XPointer: %s", reverse.XPointer)
|
||||
t.Logf("Precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision from text search, got %s", reverse.Precision)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Error("expected non-empty XPointer from text search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReversePercentageFallback(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.5, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
t.Logf("Percentage fallback precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "percentage" {
|
||||
t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer)
|
||||
}
|
||||
if reverse.XPointer != "" {
|
||||
t.Error("expected empty XPointer for percentage fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTextInNode_SingleTextNode(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>Hello world this is a test</p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package sync
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type LocatorSource string
|
||||
|
||||
@@ -26,6 +29,35 @@ func isConvertible(formatGroup string) bool {
|
||||
return formatGroup == string(FormatGroupReflowable)
|
||||
}
|
||||
|
||||
// Converters parse and cache the whole EPUB (spine + content docs), so
|
||||
// creating one per annotation re-reads the book for every entry. A small
|
||||
// bounded cache lets one request — or several — share a single parse.
|
||||
// Servers are the right place for this work: clients stay thin.
|
||||
var (
|
||||
converterMu sync.Mutex
|
||||
converterCache = map[string]*CFIConverter{}
|
||||
converterOrder []string // insertion order for eviction
|
||||
)
|
||||
|
||||
const maxCachedConverters = 8
|
||||
|
||||
func cachedConverter(epubPath string) *CFIConverter {
|
||||
converterMu.Lock()
|
||||
defer converterMu.Unlock()
|
||||
if c, ok := converterCache[epubPath]; ok {
|
||||
return c
|
||||
}
|
||||
c := NewCFIConverter(epubPath)
|
||||
converterCache[epubPath] = c
|
||||
converterOrder = append(converterOrder, epubPath)
|
||||
for len(converterOrder) > maxCachedConverters {
|
||||
oldest := converterOrder[0]
|
||||
converterOrder = converterOrder[1:]
|
||||
delete(converterCache, oldest)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func ConvertToCanonical(
|
||||
source LocatorSource,
|
||||
devicePos string,
|
||||
@@ -48,7 +80,7 @@ func ConvertToCanonical(
|
||||
if !IsCREXPointer(devicePos) {
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
||||
}
|
||||
converter := NewCFIConverter(epubPath)
|
||||
converter := cachedConverter(epubPath)
|
||||
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
||||
@@ -101,7 +133,7 @@ func ConvertFromCanonical(
|
||||
|
||||
switch source {
|
||||
case LocatorSourceKOReader:
|
||||
converter := NewCFIConverter(epubPath)
|
||||
converter := cachedConverter(epubPath)
|
||||
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
||||
|
||||
@@ -425,7 +425,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
}
|
||||
</div>
|
||||
@ProgressSyncModal(user, book)
|
||||
@NotesHighlightsModal(book)
|
||||
@NotesHighlightsModal(user, book)
|
||||
@MetadataEditorModal(book)
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
|
||||
@@ -128,25 +128,106 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
</div>
|
||||
}
|
||||
|
||||
templ NotesHighlightsModal(book handlers.MediaDetail) {
|
||||
templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
|
||||
<div
|
||||
id="notes-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div
|
||||
class="card w-full max-w-2xl p-8 text-center"
|
||||
class="card w-full max-w-2xl my-8 flex flex-col max-h-[90vh]"
|
||||
style="box-shadow: var(--shadow-pop);"
|
||||
>
|
||||
<span class="inline-grid place-items-center h-14 w-14 rounded-2xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("edit", "h-7 w-7")
|
||||
</span>
|
||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||
<p class="mb-2" style="color: var(--text-secondary);">
|
||||
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
||||
</p>
|
||||
<p class="mb-6" style="color: var(--text-secondary);">Feature coming soon!</p>
|
||||
<div>
|
||||
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("edit", "h-5 w-5")
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="hideNotesModal()"
|
||||
class="icon-btn"
|
||||
aria-label="Close"
|
||||
>
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto flex-1 min-h-0">
|
||||
<div class="flex items-center justify-center gap-6 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold" style="color: var(--accent);">{ book.HighlightsCount }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Highlights</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold" style="color: var(--accent);">{ book.NotesCount }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Notes</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold" style="color: var(--text-secondary);">{ len(book.DeletedAnnotations) }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Deleted</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if len(book.DeletedAnnotations) == 0 {
|
||||
<div class="text-center py-6">
|
||||
<p style="color: var(--text-secondary);">No deleted annotations. Highlights, notes, and bookmarks removed on the web or on a synced device appear here for recovery.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Recently deleted</h3>
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--text-secondary);">
|
||||
Deleted entries are kept for the sync retention window before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
for _, ann := range book.DeletedAnnotations {
|
||||
<div class="card p-4" data-deleted-annotation={ ann.ID }>
|
||||
<div class="flex justify-between items-start gap-3 mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ ann.AnnotationType }</span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);">
|
||||
{ FormatInTimezone(ann.DeletedAt, user.Timezone) }
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
@click={"restoreDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')"}
|
||||
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||
>
|
||||
@Icon("refresh", "h-3.5 w-3.5")
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
@click={"purgeDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')"}
|
||||
class="btn btn-ghost text-xs px-3 py-1.5"
|
||||
style="color: var(--status-error);"
|
||||
>
|
||||
@Icon("close", "h-3.5 w-3.5")
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
if ann.DisplayText != "" {
|
||||
<p class="text-sm line-clamp-3" style="color: var(--text-primary);">{ ann.DisplayText }</p>
|
||||
} else {
|
||||
<p class="text-sm italic" style="color: var(--text-secondary);">(no text)</p>
|
||||
}
|
||||
if ann.SecondaryText != "" {
|
||||
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-secondary);">{ ann.SecondaryText }</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
<button
|
||||
@click="hideNotesModal()"
|
||||
class="btn btn-primary"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1336,7 +1336,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = NotesHighlightsModal(book).Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = NotesHighlightsModal(user, book).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ interface MetadataEditorState {
|
||||
clearRating(): Promise<void>;
|
||||
toggleRead(read: boolean): Promise<void>;
|
||||
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
||||
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
||||
purgeDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
||||
}
|
||||
|
||||
Alpine.data("bookDetail", () => {
|
||||
@@ -194,6 +196,65 @@ Alpine.data("bookDetail", () => {
|
||||
}
|
||||
},
|
||||
|
||||
async restoreDeletedAnnotation(annotationType: string, annotationId: string) {
|
||||
const mediaId = getMediaId();
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${mediaId}/annotations/${annotationId}/restore`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: getAuthHeader(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ annotation_type: annotationType }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Failed to restore annotation");
|
||||
}
|
||||
showToast("Annotation restored", "success");
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} catch (e) {
|
||||
showToast(
|
||||
e instanceof Error ? e.message : "Failed to restore annotation",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
|
||||
if (
|
||||
!confirm(
|
||||
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const mediaId = getMediaId();
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${mediaId}/annotations/${annotationId}?annotation_type=${encodeURIComponent(annotationType)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Failed to delete annotation");
|
||||
}
|
||||
showToast("Annotation permanently deleted", "success");
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} catch (e) {
|
||||
showToast(
|
||||
e instanceof Error ? e.message : "Failed to delete annotation",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
init() {
|
||||
const ratingAttr = document.body.getAttribute("data-rating");
|
||||
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
||||
|
||||
@@ -412,6 +412,8 @@ document.addEventListener("alpine:init", () => {
|
||||
note: string;
|
||||
color: string;
|
||||
cfi: string;
|
||||
cfiEnd: string;
|
||||
renderCfi: string;
|
||||
percentage: number;
|
||||
pdfPage: number;
|
||||
pdfRects: number[][];
|
||||
@@ -443,6 +445,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: 0,
|
||||
text: "",
|
||||
cfi: "",
|
||||
cfiEnd: "",
|
||||
id: "",
|
||||
color: "#ffd54f",
|
||||
note: "",
|
||||
@@ -701,8 +704,15 @@ document.addEventListener("alpine:init", () => {
|
||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||
if (!text) return;
|
||||
let cfi: string;
|
||||
let cfiEnd: string;
|
||||
try {
|
||||
cfi = this.view.getCFI(index, range);
|
||||
// Collapse to the end point for a distinct end anchor —
|
||||
// KOReader sync renders the highlight box from pos0/pos1, and
|
||||
// pos1 == pos0 would be a degenerate (zero-length) range.
|
||||
const endRange = range.cloneRange();
|
||||
endRange.collapse(false);
|
||||
cfiEnd = this.view.getCFI(index, endRange);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -715,6 +725,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: (iframeRect?.top ?? 0) + rect.top,
|
||||
text,
|
||||
cfi,
|
||||
cfiEnd,
|
||||
});
|
||||
};
|
||||
doc.addEventListener(
|
||||
@@ -827,6 +838,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: (iframeRect?.top ?? 0) + rect.top,
|
||||
text: h.text,
|
||||
cfi: h.cfi,
|
||||
cfiEnd: h.cfiEnd,
|
||||
id: h.id,
|
||||
color: h.color,
|
||||
note: h.note,
|
||||
@@ -1070,6 +1082,7 @@ document.addEventListener("alpine:init", () => {
|
||||
y: number;
|
||||
text: string;
|
||||
cfi: string;
|
||||
cfiEnd?: string;
|
||||
id?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
@@ -1080,6 +1093,7 @@ document.addEventListener("alpine:init", () => {
|
||||
p.mode = opts.mode;
|
||||
p.text = opts.text;
|
||||
p.cfi = opts.cfi;
|
||||
p.cfiEnd = opts.cfiEnd ?? "";
|
||||
p.id = opts.id ?? "";
|
||||
p.color = opts.color || "#ffd54f";
|
||||
p.note = opts.note ?? "";
|
||||
@@ -1109,7 +1123,7 @@ document.addEventListener("alpine:init", () => {
|
||||
} else {
|
||||
this.view
|
||||
?.addAnnotation({
|
||||
value: hl.cfi,
|
||||
value: hl.renderCfi || hl.cfi,
|
||||
color: hl.color,
|
||||
note: hl.note,
|
||||
id: hl.id,
|
||||
@@ -1141,17 +1155,68 @@ document.addEventListener("alpine:init", () => {
|
||||
/* not ours; leave as-is */
|
||||
}
|
||||
}
|
||||
const cfiEnd = r.epubcfi_end ?? "";
|
||||
return {
|
||||
id: r.id,
|
||||
text: r.selection_text ?? "",
|
||||
note: r.note_text ?? "",
|
||||
color: r.color ?? "#ffff00",
|
||||
cfi,
|
||||
cfiEnd,
|
||||
// Rendering/navigating anchor: device-synced highlights store
|
||||
// POINT CFIs (epubcfi(/6/N!/4/2[id]/8/1:1)), which resolve to a
|
||||
// collapsed range and paint nothing. Foliate's overlayer needs a
|
||||
// RANGE CFI — same shape getCFI() produces natively
|
||||
// (epubcfi(/6/N!/4/2[id],/8/1:1,/8/1:67)) — synthesized here from
|
||||
// the stored start and end points when both share a base path.
|
||||
renderCfi: this.toRangeCfi(cfi, cfiEnd, r.selection_text ?? ""),
|
||||
percentage: r.percentage_start ?? 0,
|
||||
pdfPage,
|
||||
pdfRects,
|
||||
};
|
||||
},
|
||||
// Build a foliate-renderable RANGE CFI from stored (possibly point)
|
||||
// CFIs. Repairs two stale shapes using the selection text: a missing
|
||||
// end (old web highlights), and a degenerate end — the device-push
|
||||
// converter used to fall back to a document-start CFI when the end
|
||||
// xpointer didn't resolve exactly. In both cases the end is derived
|
||||
// from the start offset advanced by the text's UTF-16 length (EPUB
|
||||
// CFI offsets are UTF-16 code units); multi-node selections just fail
|
||||
// resolution harmlessly and fall back to the point CFI.
|
||||
toRangeCfi(start: string, end: string, text: string): string {
|
||||
if (!start) return end || start;
|
||||
if (start.includes(",")) return start; // already a range CFI
|
||||
const re =
|
||||
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/;
|
||||
const ms = re.exec(start);
|
||||
if (!ms) return start;
|
||||
const base = ms[1];
|
||||
const startLocal = ms[2];
|
||||
const startOff = ms[3] ? parseInt(ms[3], 10) : -1;
|
||||
const utf16len = [...(text ?? "")].reduce(
|
||||
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
|
||||
0,
|
||||
);
|
||||
let endLocal = "";
|
||||
if (end && !end.includes(",")) {
|
||||
const me = re.exec(end);
|
||||
if (me && me[1] === base) {
|
||||
const endOff = me[3] ? parseInt(me[3], 10) : -1;
|
||||
// Degenerate: end resolves to the document start (the old
|
||||
// converter fallback) or sits before the start offset.
|
||||
const degenerate =
|
||||
endOff === 0 ||
|
||||
(startOff >= 0 && endOff >= 0 && endOff < startOff);
|
||||
if (!degenerate) endLocal = me[2];
|
||||
}
|
||||
}
|
||||
if (!endLocal) {
|
||||
if (startOff < 0 || utf16len <= 0) return start; // point CFI
|
||||
const cut = startLocal.lastIndexOf(":");
|
||||
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`;
|
||||
}
|
||||
return `${base},${startLocal},${endLocal})`;
|
||||
},
|
||||
async refreshAnnotations() {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId) return;
|
||||
@@ -1205,6 +1270,7 @@ document.addEventListener("alpine:init", () => {
|
||||
start_position: "",
|
||||
end_position: "",
|
||||
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||||
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
||||
color,
|
||||
note_text: "",
|
||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||
@@ -1230,7 +1296,10 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
} else {
|
||||
this.view?.addAnnotation({
|
||||
value: p.cfi,
|
||||
value:
|
||||
p.pdfPage >= 0
|
||||
? ""
|
||||
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||
color,
|
||||
note: "",
|
||||
id: row.id,
|
||||
@@ -1263,6 +1332,7 @@ document.addEventListener("alpine:init", () => {
|
||||
start_position: "",
|
||||
end_position: "",
|
||||
epubcfi_start: anchor,
|
||||
epubcfi_end: p.pdfPage >= 0 ? anchor : p.cfiEnd,
|
||||
color: p.color,
|
||||
note_text: p.note,
|
||||
}),
|
||||
@@ -1282,7 +1352,7 @@ document.addEventListener("alpine:init", () => {
|
||||
});
|
||||
} else {
|
||||
this.view?.addAnnotation({
|
||||
value: p.cfi,
|
||||
value: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||
color: p.color,
|
||||
note: p.note,
|
||||
id: p.id,
|
||||
@@ -1324,6 +1394,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToHighlight(hl: {
|
||||
cfi: string;
|
||||
renderCfi: string;
|
||||
pdfPage: number;
|
||||
}) {
|
||||
if (hl.pdfPage >= 0) {
|
||||
@@ -1331,9 +1402,11 @@ document.addEventListener("alpine:init", () => {
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(hl.pdfPage);
|
||||
this.closeDrawers();
|
||||
} else if (hl.cfi) {
|
||||
} else if (hl.renderCfi || hl.cfi) {
|
||||
this.pushBackStack();
|
||||
this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
|
||||
this.view
|
||||
?.showAnnotation({ value: hl.renderCfi || hl.cfi })
|
||||
?.catch?.(() => {});
|
||||
this.closeDrawers();
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user