21 Commits
Author SHA1 Message Date
john-okeefe d429534b12 docs(api): deleted-annotation history + KOReader deletion propagation
Release / build-and-push (push) Successful in 2m24s
New media-items/deleted_annotations.md for the list/restore/purge
endpoints; endpoint index updated. The KOReader protocol page documents
deleted_highlights/deleted_bookmarks on the progress push and the
deletion-propagation contract: keys learned only from server pulls,
explicit arrays only (never absence), tombstone convergence via the
metadata fetch, no resurrection from stale replays, and the web history
as the restore path.
2026-08-22 13:16:54 -04:00
john-okeefe f70579b4fc feat(ui): deleted-annotation history on the book page
Replace the Notes & Highlights 'coming soon' stub with a real modal:
active counts plus a 'Recently deleted' section listing every tombstoned
highlight, note, and bookmark (type badge, deletion time in the user's
timezone, text preview), each with Restore and Delete-permanently
actions. Restore returns the annotation to every synced device; Delete
permanently is confirmed before purging. The list is server-rendered
from MediaDetail.DeletedAnnotations — no fetch on open.

Alpine handlers in book-detail.ts call the new restore/purge endpoints
and reload on success. style.css picks up the line-clamp utilities used
by the text previews.
2026-08-22 13:16:48 -04:00
john-okeefe 1f5c5a28bd feat(sync): propagate KOReader annotation deletions + history API
KOReader push (processBookAnnotations) accepts deleted_highlights and
deleted_bookmarks arrays of dedup keys and tombstones the matching rows,
after the upserts so a key present in both lists resolves to 'deleted'
(the newer intent). Deletions remain soft: rows stay restorable from the
history and echo to other devices as tombstones on their next pull. A
stale device replay of the annotation cannot resurrect the tombstone —
device pushes carry no modification timestamp, so the save loses to the
delete. Absence from these arrays is never a delete, keeping category
toggles safe.

New annotation-history endpoints (annotation_history.go, media.go):
  GET    /api/media-items/:id/annotations/deleted
  POST   /api/media-items/:id/annotations/:annotationId/restore
  DELETE /api/media-items/:id/annotations/:annotationId
All scoped to the authenticated user and the route's book; the DELETE is
the permanent purge (annotation_type required in query or body).

MediaDetail gains DeletedAnnotations, populated by the book page route
via the shared DeletedAnnotationsForBook builder, so the server-rendered
history ships with the page instead of requiring a client round-trip.

Binding tests cover the plugin's exact wire shape and the legacy
plugin case (arrays omitted -> empty).
2026-08-22 13:16:43 -04:00
john-okeefe d4c52e9a6a feat(sync): restore/purge service methods + bookmark tombstone by dedup key
RestoreAnnotationByID and PurgeAnnotationByID dispatch on annotation kind
(highlight/note/bookmark) to the new queries, broadcasting an annotation
update on restore so connected web sessions refresh. Both report whether
a row actually changed.

TombstoneBookmarkByDedupKey mirrors the existing TombstoneHighlight for
bookmarks: devices report deletions by dedup key (they have no row IDs),
and until now only highlights had a key-based tombstone path — device
bookmark deletions had nowhere to land.

ValidAnnotationKind centralizes the kind check the HTTP handlers share.
2026-08-22 13:16:36 -04:00
john-okeefe acbb6c7981 feat(db): queries for the deleted-annotation history
ListDeletedAnnotationsForBook unions tombstoned highlights, notes, and
bookmarks for a user+book regardless of the sync TTL cutoff (the history
must show everything still restorable, not just recent deletes), with
display text, secondary text, color, and both timestamps.

Restore queries clear deleted/deleted_at (lossless — the row was soft-
deleted, never removed) and are scoped to the owning user and media item
so a restore can never touch another user's annotation.

Purge queries hard-delete an already-tombstoned row: the user-driven
counterpart of the TTL maintenance sweep, for explicit 'delete
permanently' actions from the history.

All six write queries are :execrows so callers can distinguish 'restored'
from 'nothing matched' without a follow-up read.
2026-08-22 13:16:31 -04:00
john-okeefe 91c8be8562 docs(api): document the KOReader resolve endpoint
Add koreader/resolve_book.md for GET /api/sync/koreader/resolve, list
the endpoint in the API reference, and describe the resolve-then-pull-
then-push linking flow in the KOReader protocol page — including why a
device pushing to bootstrap its identity creates progress conflicts for
books already mid-read from other sources.
2026-08-22 09:57:44 -04:00
john-okeefe 6859f81144 feat(sync): add read-only KOReader book resolve endpoint
GET /api/sync/koreader/resolve?sha256={hash} maps a file content hash to
the book's UUID through the shared format-aware BookResolver (primary
media_items hash, then per-format hashes so converted KEPUB/PDF files
match) without touching any progress state.

Devices need the UUID to pull metadata, but a freshly downloaded book has
none cached. The old way of learning it was to push once, which
transmitted the device's first-page position and manufactured a progress
conflict for books already mid-read from another source. A read-only
lookup lets clients link (and pull) without ever pushing bootstrap
progress: resolve, then pull, then push.

Returns 200 {book_uuid, sha256, title, author}, 400 for a missing or
malformed hash, 404 when no library item matches.
2026-08-22 09:57:35 -04:00
john-okeefe 078c4b1f3f docs(api): add system/hash-conflict sections to API references
Monolithic api-reference.md:
- New 'System Settings & Configuration' and 'Hash Conflicts' sections
  (endpoints, examples, response shapes) with TOC entries
- Device Management: add the sidecar config/download endpoints
- Fix stale registration flow: correct auth_url path, drop phantom
  device_id, add poll_interval/setup_instructions, status endpoint is
  POST /api/devices/register/status, and sync_endpoints point at
  /api/sync/koreader/*
- Mark PUT /api/libraries/scan-settings as legacy/superseded
- Repair Additional Resources and Collections links (dead
  COLLECTIONS_API.md / KOBO*_SETUP.md / missing-guide references)

Split api-reference.md index:
- Quick links and sections for System (settings + config) and the
  admin hash-conflict endpoints; device sidecar endpoints under Device
  Management; browse + legacy scan-settings routes under Libraries
2026-08-20 14:41:05 -04:00
john-okeefe c49a9605ff docs(api): rewrite KOReader bookmark sync for current protocol
sync_bookmarks.md documented a request shape the handler never accepted.

- Document the real body: book_uuid/book_sha256 (either required,
  SHA-256 is format-aware), plus separate bookmarks/notes/highlights
  arrays using the shared KOReader annotation shape (pos0/pos1, page,
  text, type, per-annotation book_sha256, dedup_key, percentage)
- Document color semantics from 178fb2e/dafcadd: KOReader palette
  names map to web hex swatches at the boundary, echoes carry no color
  so stored web colors survive round-trips, explicit colors are device
  edits
- koreader-protocol.md: cross-link the bookmark shape/color/dedup
  rules from the progress-sync field table
2026-08-20 14:40:59 -04:00
john-okeefe df90938c5c docs(api): document device sidecar endpoints; fix registration docs
- New get_sidecar_config.md for GET /api/devices/:id/sidecar and
  /sidecar/download: the .bookhoard.json config served to devices
  (endpoints, books keyed by per-format SHA-256 with UUID fallback,
  collections, format availability) used by the KOReader plugin to
  self-configure
- register_device.md: correct the response — no device_id at
  registration; auth_url is /devices/approve/:id (was the nonexistent
  /devices/auth/confirm/:id); document poll_interval and
  setup_instructions, and the approve-then-poll flow
- get_devices.md: fix the status endpoint path to
  POST /api/devices/register/status (was /api/devices/auth/status)
2026-08-20 14:40:54 -04:00
john-okeefe ffcdab36a0 docs(api): document hash-conflict resolution endpoints
Cover the admin API added in 03cb4c7 for duplicate-content decisions:

- GET /api/admin/hash-conflicts — pending conflict groups with member
  items and per-item usage counts (progress, highlights, bookmarks,
  notes, collections)
- POST /api/admin/hash-conflicts/:id/resolve — action=keep (merge child
  rows into keep_uuid, delete losers) vs action=keep_all (dismiss);
  JSON and form-encoded bodies, error codes including 409 for already
  resolved
- When conflicts are created (startup backfill, rescans) and the
  guarantee that files on disk are never deleted
2026-08-20 14:40:50 -04:00
john-okeefe 44a0f8c7a4 docs(api): document unified tunable system settings endpoints
The scattered scan-settings JSON routes are superseded by the new
admin-only /api/system/settings pair backed by the SettingsRegistry
(introduced in 885f6d8 / bc47450).

- Rewrite system/settings.md around GET/PUT /api/system/settings:
  SettingEntry metadata shape (type, min/max, requires_restart,
  category, group, is_default), type-aware validation rules, and the
  full tunable-setting catalog (scanner, general, security, api, sync,
  performance) with defaults, ranges, and restart requirements
- Note the legacy /api/libraries/scan-settings routes as back-compat
  only (they now refresh the registry cache on write)
- Add system/config.md for GET/PUT /api/system/config: raw key/value
  system configuration (e.g. base_url), including validation notes and
  guidance to prefer the typed settings endpoint for registry keys
2026-08-20 14:40:45 -04:00
john-okeefe 8ca95db08a docs(user): update admin and collections instructions for sidebar UI
Admin:
- Admin pages live in the sidebar's Administration panel (Dashboard,
  Libraries, Hash Conflicts, Users, Settings)
- Library creation is the Create Library modal (Library Name,
  Description, Library Type); folders are added afterwards by expanding
  the library row and using the Folders section's path input + Browse +
  Add — the old Add Library modal with folder and 'Scan on save' fields
  no longer exists
- Scanning is via the Scanner API or watch mode (File Watcher status on
  the admin dashboard); remove references to the removed per-library
  Rescan button and 'Force Rescan' option

Collections:
- Fill in the empty creating/managing placeholders with the real flow:
  New Collection button, modal fields (name, description, icon grid,
  color swatches), per-collection edit/delete icon buttons, Restore
  System button, and dashboard-section visibility via Customize
  Dashboard
2026-08-20 14:30:10 -04:00
john-okeefe 9a60196f1d docs(user): update dashboard and bookshelf instructions for sidebar UI
Dashboard:
- Customize Dashboard is opened from the icon button at the right end
  of the Library bar (next to Refresh), and requires a specific library
  selected rather than 'All Libraries'
- Library switching uses the Library dropdown in the bar below the top
  bar (includes 'All Libraries' with counts)
- Collection sections are shown/hidden from the Customize Dashboard
  toggles — the per-collection 'Show on Dashboard' setting is gone
- Mention the hover chevrons for scrolling carousels

Bookshelf:
- Saved filters: document the new toolbar buttons — Filters (opens the
  filter drawer with Apply Filters, Esc, and overlay-click close), Save,
  Load (Saved Filters dropdown with trash-icon delete), and Clear —
  replacing the emoji-labelled Save Filter / Saved Filters buttons
- Tag filtering: filters now live behind the Filters drawer on the All
  Books page
2026-08-20 14:30:05 -04:00
john-okeefe 7274b5196c docs(user): update appearance/profile instructions for sidebar UI
- Theme switching lives in the sidebar's Appearance panel (palette
  icon): swatch list with a checkmark on the active theme, and the
  'Bookshelf' section below it for wood textures; on small screens the
  sidebar opens via the top-bar menu button
- Profile: account menu is the username accordion at the bottom of the
  sidebar (not top-right); save button is 'Save Changes'
- Remove Wood Light/Dark/Mahogany from the Available Themes list (they
  are bookshelf backgrounds, not color themes) and consolidate the
  Catppuccin variants
2026-08-20 14:30:00 -04:00
john-okeefe 868003331c docs(user): update device and sync navigation for sidebar UI
The new-ui redesign replaced the top header with a sidebar and removed
the Settings pages.

- Replace all 'Settings → Devices' paths with the Devices page in the
  sidebar
- Conflicts are resolved from the book detail page's Sync Progress
  button or the Conflicts page (/conflicts); drop the nonexistent
  'Settings → Conflicts' path
- Queue status and unlinked-book references no longer invent per-device
  button paths that don't exist on the Devices page
- Reading history now points to the Progress page / book detail
- Export FAQ no longer references a Settings → Export flow that isn't
  in the UI
2026-08-20 14:29:54 -04:00
john-okeefe e310fa6a9d docs(index): update device guidance and fix broken links
- Device setup and quick-find entries now lead with the KOReader guide
  and label native Kobo sync as coming soon
- Kobo protocol/API listings tagged as a coming-soon feature
- Fix seven pre-existing broken links: contributing/Development.md had
  the wrong case (development.md), and PROJECT_GUIDELINES.md links were
  missing the ../ prefix to reach the repo root
- Refresh last-updated stamp
2026-08-20 14:14:55 -04:00
john-okeefe e35d394736 docs(readme): update device support matrix to current state
- Supported-devices table: Kobo moves from 'full support' to 'coming
  soon, use KOReader on Kobo today'; mobile apps 'coming later' with no
  speculative date
- Universal-sync pitch now states what actually syncs (position,
  bookmarks, highlights, notes) between KOReader and the web
- Frame KEPUB conversion and collection shelf mappings as groundwork
  for upcoming native Kobo support
- Fix dead links: docs/DEVELOPMENT.md → docs/developer/development.md
  and docs/contributing/DEVELOPMENT.md → actual path
2026-08-20 14:14:52 -04:00
john-okeefe 2a7ac881fd docs(user): align sync and user guides with current device support
- sync-guide: only Web and KOReader are fully supported; move Kobo to
  coming soon, drop fake Q2-Q4 2026 release dates for mobile/Kindle/
  Remarkable, and describe the plugin + server-approval registration
  flow instead of QR-code/URL approval
- sync-guide: remove cellular/mobile-app advice from battery and
  best-practice sections, correct the Calibre compatibility FAQ, update
  the changelog to reflect shipped vs. pending sync features, and fix
  the license header (GPL-3.0, not MIT)
- user-guide: lead device setup with KOReader; mark the Kobo guide as
  coming soon
- calibre-integration: OPDS client list no longer implies native Kobo
  support
- auth overview: label the mobile-application token guidance as
  'coming later' since no mobile apps exist yet
2026-08-20 14:14:49 -04:00
john-okeefe 26f1f98736 docs(kobo): mark native Kobo sync as coming soon
Native Kobo sync is implemented server-side but not yet supported on
real devices, so stop documenting it as a working feature.

- Rewrite kobo-setup.md as a coming-soon stub: point users to KOReader
  (which runs on Kobo hardware) as the supported path today, and list
  what native sync will deliver when released
- Add 'Coming Soon' status banners to the Kobo protocol spec, all five
  Kobo endpoint docs, and both API references, noting the endpoints are
  under active development and may change
- Tag the device shelf endpoints as pending native Kobo support
2026-08-20 14:14:43 -04:00
john-okeefe 54d0550dec docs(koreader): rewrite setup guide for plugin + server-side approval flow
Replace the outdated Calibre-wireless/Basic-Auth instructions with the
actual current flow: install the bookhoard.koplugin plugin, enter the
server URL in the plugin menu, then approve the pending registration
from Settings → Devices. Registration tokens are delivered to the
plugin automatically after approval (5-minute expiry), so no
credentials are ever typed on the device.

Also document bidirectional sync of position, bookmarks, highlights
(colors mapped between web and KOReader palettes), and notes, plus
format-aware SHA-256 book matching, OPDS delivery, and trimmed
troubleshooting sections covering the new registration flow.
2026-08-20 14:14:39 -04:00
50 changed files with 2597 additions and 1817 deletions
+13 -13
View File
@@ -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
View File
@@ -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)
---
+111
View File
@@ -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
+26 -2
View File
@@ -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
+1 -1
View File
@@ -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`
+2
View File
@@ -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`
+2
View File
@@ -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 |
+70 -36
View File
@@ -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 |
+2
View File
@@ -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
+64 -1
View File
@@ -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
+77
View File
@@ -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/))
+116 -109
View File
@@ -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
View File
@@ -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
+5 -5
View File
@@ -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
+20 -20
View File
@@ -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
+19 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+70 -406
View File
@@ -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
+28 -30
View File
@@ -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
+9 -9
View File
@@ -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
+5 -4
View File
@@ -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
View File
@@ -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 conflictChoose winner
Book detail → Sync Progresschoose 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
View File
@@ -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.
+5 -7
View File
@@ -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
+16
View File
@@ -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
+217
View File
@@ -8127,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
`
@@ -9419,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,
@@ -9752,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
`
+81
View File
@@ -1027,6 +1027,87 @@ 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)
+167
View File
@@ -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
}
+90 -18
View File
@@ -158,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 {
@@ -734,6 +745,30 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
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 {
@@ -883,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
+52
View File
@@ -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)
}
+4
View File
@@ -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"`
}
+8 -7
View File
@@ -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
+7
View File
@@ -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)
+1
View File
@@ -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))
+93
View File
@@ -268,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 {
+1 -1
View File
@@ -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>
+93 -12
View File
@@ -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 &amp; 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
+1 -1
View File
@@ -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
}
+61
View File
@@ -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;
+1 -1
View File
File diff suppressed because one or more lines are too long