Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bec4e60bd | ||
|
|
dd963993eb | ||
|
|
a431dfa10b | ||
|
|
e957d385ec | ||
|
|
06ec312863 | ||
|
|
7d88ec5184 | ||
|
|
0c31e9b88d | ||
|
|
d15f115505 | ||
|
|
5211edf264 | ||
|
|
3e98fadb9f | ||
|
|
fef79b1836 | ||
|
|
8fbacd9f46 | ||
|
|
ab5c52c709 | ||
|
|
35b210951a | ||
|
|
4bd2788c7d | ||
|
|
e40be80e78 | ||
|
|
8a40349fa1 | ||
|
|
51613566ac | ||
|
|
0d4698c0de | ||
|
|
607e5a1708 | ||
|
|
39b0ab03fe | ||
|
|
535847477e | ||
|
|
aee1aafc74 | ||
|
|
3e55ddd88a | ||
|
|
ecb6a7385a | ||
|
|
88e3de606c | ||
|
|
b3a8cbd582 | ||
|
|
ed550fa914 | ||
|
|
89fc479bdb | ||
|
|
20754f15e2 | ||
|
|
bb141cdffc | ||
|
|
393a00e8dd | ||
|
|
7e2e1c4c60 | ||
|
|
2165829d0e | ||
|
|
04e855e691 | ||
|
|
3e8ac3b9b7 | ||
|
|
10a5ac88da | ||
|
|
3feb608d26 | ||
|
|
9b2d3da7e2 | ||
|
|
788e9463cf | ||
|
|
ef4de7896a | ||
|
|
12aab156f6 | ||
|
|
c1a7648076 | ||
|
|
9e2f216af5 | ||
|
|
ceed3ddeae | ||
|
|
0673550d58 | ||
|
|
71b171bf72 | ||
|
|
2df6f8e55c | ||
|
|
88adf30aa9 | ||
|
|
e3ca3426ba | ||
|
|
42b283480a | ||
|
|
913174cdf8 | ||
|
|
bf4735f3ce | ||
|
|
e07301f74a | ||
|
|
fbdd4ab9f9 | ||
|
|
ef827a3a05 | ||
|
|
7b7ce39826 | ||
|
|
9cf9e1d305 | ||
|
|
daffe499eb | ||
|
|
0d613ba0e3 | ||
|
|
a4dc03984c | ||
|
|
1ed1602e70 | ||
|
|
2344f5686a |
No files matched your search
@@ -1,6 +1,14 @@
|
|||||||
# Screenshot Automation Plan for Bookhoard Documentation
|
# Screenshot Automation Plan for Bookhoard Documentation
|
||||||
|
|
||||||
> **Status**: Ready to implement when frontend is complete
|
> **Status**: REJECTED / ARCHIVED (2026-11-02) — not implementing.
|
||||||
|
> **Superseded by**: [docs/developer/writing-ui-docs.md](docs/developer/writing-ui-docs.md) —
|
||||||
|
> Bookhoard docs are written screenshot-free from now on. Why: screenshots go
|
||||||
|
> stale silently (no gate can diff pixels against the UI), die on every
|
||||||
|
> redesign, and are unverifiable; the UI is server-rendered so exact labels are
|
||||||
|
> greppable and gate-checked instead (`TestUIControlLabels`). The prose
|
||||||
|
> standard teaches patterns that survive re-layouts. Kept below unmodified for
|
||||||
|
> history.
|
||||||
|
>
|
||||||
> **Last Updated**: 2026-02-03
|
> **Last Updated**: 2026-02-03
|
||||||
|
|
||||||
This document outlines the complete plan for automatically generating screenshots for Bookhoard documentation using Playwright.
|
This document outlines the complete plan for automatically generating screenshots for Bookhoard documentation using Playwright.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Bulk Add Books
|
name: Bulk Add Books
|
||||||
type: http
|
type: http
|
||||||
seq: 2
|
seq: 1
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Bulk Assign Books
|
name: Bulk Assign Books
|
||||||
type: http
|
type: http
|
||||||
seq: 3
|
seq: 2
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Bulk Remove Books
|
name: Bulk Remove Books
|
||||||
type: http
|
type: http
|
||||||
seq: 4
|
seq: 3
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Create Collection
|
name: Create Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 4
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Create Shelf Mapping
|
name: Create Shelf Mapping
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 5
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Collection
|
name: Delete Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 7
|
seq: 6
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Shelf Mapping
|
name: Delete Shelf Mapping
|
||||||
type: http
|
type: http
|
||||||
seq: 8
|
seq: 7
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Collection
|
name: Get Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 9
|
seq: 8
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Book Collections
|
name: Get Book Collections
|
||||||
type: http
|
type: http
|
||||||
seq: 10
|
seq: 9
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: List Collections
|
name: List Collections
|
||||||
type: http
|
type: http
|
||||||
seq: 11
|
seq: 10
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Preview Collection
|
name: Preview Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 12
|
seq: 11
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Reorder Collection Items
|
name: Reorder Collection Items
|
||||||
type: http
|
type: http
|
||||||
seq: 14
|
seq: 12
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Collection
|
name: Update Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 16
|
seq: 14
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
+12
-6
@@ -1,12 +1,18 @@
|
|||||||
info:
|
info:
|
||||||
name: Reject Device Registration
|
name: Cancel Device Registration
|
||||||
type: http
|
type: http
|
||||||
seq: 16
|
seq: 3
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
url: '{{base_url}}/api/devices/reject/{{registration_id}}'
|
url: '{{base_url}}/api/devices/register/cancel'
|
||||||
auth: inherit
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
}
|
||||||
settings:
|
settings:
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
@@ -23,9 +29,9 @@ runtime:
|
|||||||
|
|
||||||
docs:
|
docs:
|
||||||
content: |-
|
content: |-
|
||||||
# Reject Device Registration
|
# Cancel Device Registration
|
||||||
|
|
||||||
`POST /api/devices/reject/:registration_id` — auth: jwt.
|
`POST /api/devices/register/cancel` — auth: none.
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/devices/reject_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
Canonical reference: docs/developer/api/devices/cancel_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||||
type: text/markdown
|
type: text/markdown
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
info:
|
||||||
|
name: Claim Device (Pairing Code)
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/devices/claim'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"pairing_code": "583912"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
docs:
|
||||||
|
content: |-
|
||||||
|
# Claim Device (Pairing Code)
|
||||||
|
|
||||||
|
`POST /api/devices/claim` — auth: jwt.
|
||||||
|
|
||||||
|
Canonical reference: docs/developer/api/devices/claim_device.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||||
|
type: text/markdown
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Clear Shelf
|
name: Clear Shelf
|
||||||
type: http
|
type: http
|
||||||
seq: 3
|
seq: 5
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Delete Device
|
name: Delete Device
|
||||||
type: http
|
type: http
|
||||||
seq: 4
|
seq: 6
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Download Sidecar Config
|
name: Download Sidecar Config
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 7
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device
|
name: Get Device
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 8
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device Collection Mappings
|
name: Get Device Collection Mappings
|
||||||
type: http
|
type: http
|
||||||
seq: 7
|
seq: 9
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device / Check Registration Status
|
name: Get Device / Check Registration Status
|
||||||
type: http
|
type: http
|
||||||
seq: 8
|
seq: 10
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Shelf
|
name: Get Shelf
|
||||||
type: http
|
type: http
|
||||||
seq: 9
|
seq: 11
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Get Device Sidecar Config
|
name: Get Device Sidecar Config
|
||||||
type: http
|
type: http
|
||||||
seq: 10
|
seq: 12
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Link Book
|
name: Link Book
|
||||||
type: http
|
type: http
|
||||||
seq: 11
|
seq: 13
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: List User Devices
|
name: List User Devices
|
||||||
type: http
|
type: http
|
||||||
seq: 12
|
seq: 14
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
||||||
type: http
|
type: http
|
||||||
seq: 13
|
seq: 15
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: GET
|
method: GET
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
info:
|
||||||
|
name: Look Up Pairing Code
|
||||||
|
type: http
|
||||||
|
seq: 16
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: '{{base_url}}/api/devices/claim/lookup'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"pairing_code": "583912"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test("status is 200", function() {
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
docs:
|
||||||
|
content: |-
|
||||||
|
# Look Up Pairing Code
|
||||||
|
|
||||||
|
`POST /api/devices/claim/lookup` — auth: jwt.
|
||||||
|
|
||||||
|
Canonical reference: docs/developer/api/devices/lookup_pairing_code.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
|
||||||
|
type: text/markdown
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Regenerate Device Token
|
name: Regenerate Device Token
|
||||||
type: http
|
type: http
|
||||||
seq: 14
|
seq: 17
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Register Device
|
name: Register Device
|
||||||
type: http
|
type: http
|
||||||
seq: 15
|
seq: 18
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Remove from Shelf
|
name: Remove from Shelf
|
||||||
type: http
|
type: http
|
||||||
seq: 17
|
seq: 19
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Device
|
name: Update Device
|
||||||
type: http
|
type: http
|
||||||
seq: 18
|
seq: 20
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Update Device Collection Mapping
|
name: Update Device Collection Mapping
|
||||||
type: http
|
type: http
|
||||||
seq: 19
|
seq: 21
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -168,7 +168,13 @@ func createContractDeviceNamed(t *testing.T, setup *TestServerSetup, name, devic
|
|||||||
DeviceMetadata: []byte("{}"),
|
DeviceMetadata: []byte("{}"),
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Should create sync device")
|
require.NoError(t, err, "Should create sync device")
|
||||||
_ = device
|
// Self-delete at test end: the contract helper attaches to the
|
||||||
|
// SHARED admin (the web progress push adopts the device name, so
|
||||||
|
// conflict tests drive a second named source without a separate
|
||||||
|
// account) and names it for its surface — "koreader" passes every
|
||||||
|
// name-based sweep, so without this the row survived forever (294
|
||||||
|
// of them ended up on the dev account's device grid).
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteDevice(context.Background(), device.ID) })
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,6 +1214,30 @@ func pinAnnotationLifecycle(t *testing.T, setup *TestServerSetup, fx contractFix
|
|||||||
require.NoError(t, json.Unmarshal(raw, &body2))
|
require.NoError(t, json.Unmarshal(raw, &body2))
|
||||||
assertResponseShape(t, "annotation "+kind+" update", body2, rowKeys, rowKeys)
|
assertResponseShape(t, "annotation "+kind+" update", body2, rowKeys, rowKeys)
|
||||||
|
|
||||||
|
// The restore half of the round trip: what the reader fetches
|
||||||
|
// when the book is reopened must return the persisted annotation
|
||||||
|
// with the update applied — written to the DB, read back through
|
||||||
|
// the same list endpoint the UI loads on open.
|
||||||
|
t.Run(kind+" GET list restores the annotation", func(t *testing.T) {
|
||||||
|
resp3 := doReq(t, "GET", annotationURL(setup.Server.URL, fx.itemID, kind, ""), nil, setup.RegularToken)
|
||||||
|
defer resp3.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp3.StatusCode, "list must stay 200")
|
||||||
|
var list []map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp3.Body).Decode(&list))
|
||||||
|
var found map[string]interface{}
|
||||||
|
for _, row := range list {
|
||||||
|
if row["id"] == id {
|
||||||
|
found = row
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, found, "created %s must come back in the list that restores a reopened book", kind)
|
||||||
|
updates, _ := updateBody.(map[string]interface{})
|
||||||
|
for key, want := range updates {
|
||||||
|
assert.Equal(t, want, found[key], "%s field must round-trip from the DB on restore", key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run(kind+" DELETE response", func(t *testing.T) {
|
t.Run(kind+" DELETE response", func(t *testing.T) {
|
||||||
resp3 := doReq(t, "DELETE", annotationURL(setup.Server.URL, fx.itemID, kind, id), nil, setup.RegularToken)
|
resp3 := doReq(t, "DELETE", annotationURL(setup.Server.URL, fx.itemID, kind, id), nil, setup.RegularToken)
|
||||||
defer resp3.Body.Close()
|
defer resp3.Body.Close()
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Pairing-code flow integration tests. The register response mints a
|
||||||
|
// 6-digit code shown on the registering device's screen; the person
|
||||||
|
// holding the device types it into THEIR account (POST /api/devices/claim).
|
||||||
|
// This replaces the retired broadcast-list approve, where
|
||||||
|
// first-click-wins let any account claim (or reject) a device it could
|
||||||
|
// see but not name.
|
||||||
|
//
|
||||||
|
// Devices claimed here land on the fresh per-run regular user, so the
|
||||||
|
// harness cascade removes them; identifiers are uuid-suffixed to stay
|
||||||
|
// clear of the UNIQUE(device_identifier) index across runs.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerPairingDevice initiates a registration and returns its decoded
|
||||||
|
// response (registration_id, pairing_code, ...).
|
||||||
|
func registerPairingDevice(t *testing.T, setup *TestServerSetup, identifier string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
regRequest := map[string]interface{}{
|
||||||
|
"device_name": "Test Pairing Device",
|
||||||
|
"device_type": "koreader",
|
||||||
|
"device_identifier": identifier,
|
||||||
|
}
|
||||||
|
regBody, _ := json.Marshal(regRequest)
|
||||||
|
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
||||||
|
|
||||||
|
var regResponse map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
||||||
|
return regResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollRegistrationStatus hits the device-side status endpoint (no auth —
|
||||||
|
// the registration UUID is the bearer).
|
||||||
|
func pollRegistrationStatus(t *testing.T, setup *TestServerSetup, registrationID string) (int, map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
||||||
|
req := httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
return rec.Code, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairingCodeBody(code string) []byte {
|
||||||
|
body, _ := json.Marshal(map[string]string{"pairing_code": code})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// pairingLookupOrClaim POSTs to the given claim-family endpoint with the
|
||||||
|
// given bearer token and returns status + decoded body.
|
||||||
|
func pairingLookupOrClaim(t *testing.T, setup *TestServerSetup, path, token, code string) (int, map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("POST", path, bytes.NewReader(pairingCodeBody(code)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
return rec.Code, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrongPairingCode derives a code guaranteed to differ from the target's
|
||||||
|
// digits (rotate each digit +1 mod 10).
|
||||||
|
func wrongPairingCode(code string) string {
|
||||||
|
rotated := make([]byte, len(code))
|
||||||
|
for i := 0; i < len(code); i++ {
|
||||||
|
rotated[i] = byte('0' + (int(code[i]-'0')+1)%10)
|
||||||
|
}
|
||||||
|
return string(rotated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pairingFormRequest POSTs urlencoded form data — the Content-Type the
|
||||||
|
// browser's htmx forms actually send — with a Bearer token.
|
||||||
|
func pairingFormRequest(t *testing.T, setup *TestServerSetup, path, token, body string) (int, map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
return rec.Code, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPairingClaimAcceptsBrowserFormEncoding pins the htmx path: the
|
||||||
|
// confirm card claims via a urlencoded <form>, so echo v5 routes the
|
||||||
|
// Content-Type to the `form:` binder, which maps by `form:` struct tag
|
||||||
|
// only. (Regression: PairingCodeRequest shipped with a json-only tag,
|
||||||
|
// leaving the code empty and every browser claim failing with 400 —
|
||||||
|
// "how did lookup find the device if the code is wrong?") The JSON path
|
||||||
|
// stays pinned by the tests above; this covers the form twin.
|
||||||
|
func TestPairingClaimAcceptsBrowserFormEncoding(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
identifier := fmt.Sprintf("pairing-form-%s", uuid.New().String())
|
||||||
|
|
||||||
|
reg := registerPairingDevice(t, setup, identifier)
|
||||||
|
code := reg["pairing_code"].(string)
|
||||||
|
|
||||||
|
// Form-encoded lookup (the same call the API-side confirm card makes).
|
||||||
|
status, resp := pairingFormRequest(t, setup, "/api/devices/claim/lookup",
|
||||||
|
setup.RegularToken, "pairing_code="+code)
|
||||||
|
require.Equal(t, http.StatusOK, status, "form-encoded lookup must resolve the live code")
|
||||||
|
assert.Equal(t, "Test Pairing Device", resp["device_name"])
|
||||||
|
|
||||||
|
// Form-encoded claim — exactly what the browser's Pair device
|
||||||
|
// button sends (urlencoded hidden input, not JSON).
|
||||||
|
status, resp = pairingFormRequest(t, setup, "/api/devices/claim",
|
||||||
|
setup.RegularToken, "pairing_code="+code)
|
||||||
|
require.Equal(t, http.StatusOK, status, "form-encoded claim must bind the device")
|
||||||
|
assert.Equal(t, true, resp["approved"])
|
||||||
|
|
||||||
|
// The device's own poll delivers credentials — binding really happened.
|
||||||
|
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
||||||
|
require.Equal(t, http.StatusOK, pollStatus)
|
||||||
|
assert.Equal(t, "approved", pollResp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingClaimHappyPath(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
identifier := fmt.Sprintf("pairing-happy-%s", uuid.New().String())
|
||||||
|
|
||||||
|
reg := registerPairingDevice(t, setup, identifier)
|
||||||
|
code, ok := reg["pairing_code"].(string)
|
||||||
|
require.True(t, ok, "register response must carry pairing_code")
|
||||||
|
require.Len(t, code, 6, "pairing code must be 6 digits")
|
||||||
|
for _, d := range code {
|
||||||
|
require.GreaterOrEqual(t, d, '0')
|
||||||
|
require.LessOrEqual(t, d, '9')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup first — the confirm card's data, without binding.
|
||||||
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, " "+code[:3]+" "+code[3:])
|
||||||
|
require.Equal(t, http.StatusOK, status, "lookup should resolve the live code (spaces normalized)")
|
||||||
|
assert.Equal(t, "Test Pairing Device", resp["device_name"])
|
||||||
|
assert.NotEmpty(t, resp["expires_in"])
|
||||||
|
|
||||||
|
// Still pending — lookup must not have bound anything.
|
||||||
|
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
||||||
|
require.Equal(t, http.StatusOK, pollStatus)
|
||||||
|
assert.Equal(t, "pending", pollResp["status"])
|
||||||
|
|
||||||
|
// Claim as the fresh regular user (deterministic account per doctrine).
|
||||||
|
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
||||||
|
require.Equal(t, http.StatusOK, status, "claim should bind the device")
|
||||||
|
assert.Equal(t, true, resp["approved"])
|
||||||
|
|
||||||
|
// The device's own poll now delivers credentials.
|
||||||
|
pollStatus, pollResp = pollRegistrationStatus(t, setup, reg["registration_id"].(string))
|
||||||
|
require.Equal(t, http.StatusOK, pollStatus)
|
||||||
|
assert.Equal(t, "approved", pollResp["status"])
|
||||||
|
token, ok := pollResp["auth_token"].(string)
|
||||||
|
require.True(t, ok, "approved poll must deliver the auth token")
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
|
||||||
|
// The device row belongs to the claimer, and the poll-delivered token
|
||||||
|
// matches the stored row.
|
||||||
|
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||||
|
pgtype.UUID{Bytes: [16]byte(getRegularUserID(t, setup.DB)), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var found *database.Devices
|
||||||
|
for i := range devices {
|
||||||
|
if devices[i].DeviceIdentifier == identifier {
|
||||||
|
found = &devices[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, found, "device row must exist under the claimer")
|
||||||
|
assert.Equal(t, token, found.AuthToken, "poll-delivered token must match the stored row")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingWrongCodeIsGenericAndNonDestructive(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
reg := registerPairingDevice(t, setup, fmt.Sprintf("pairing-miss-%s", uuid.New().String()))
|
||||||
|
code := reg["pairing_code"].(string)
|
||||||
|
|
||||||
|
// A wrong code fails with the generic message — no hint whether any
|
||||||
|
// registration is waiting.
|
||||||
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, wrongPairingCode(code))
|
||||||
|
require.Equal(t, http.StatusBadRequest, status)
|
||||||
|
assert.Equal(t, "invalid pairing code", resp["error"])
|
||||||
|
|
||||||
|
// The miss must not have killed or claimed the real registration.
|
||||||
|
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
||||||
|
require.Equal(t, http.StatusOK, status, "the correct code must still claim after a miss")
|
||||||
|
assert.Equal(t, true, resp["approved"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingClaimBindsClaimerOnly(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
identifier := fmt.Sprintf("pairing-scope-%s", uuid.New().String())
|
||||||
|
|
||||||
|
reg := registerPairingDevice(t, setup, identifier)
|
||||||
|
code := reg["pairing_code"].(string)
|
||||||
|
|
||||||
|
// The regular user claims it.
|
||||||
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
|
||||||
|
// The admin (a different account) has no such device — and cannot
|
||||||
|
// re-claim the now-used code either.
|
||||||
|
adminDevices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||||
|
pgtype.UUID{Bytes: [16]byte(getTestUserID(t, setup.DB)), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, d := range adminDevices {
|
||||||
|
assert.NotEqual(t, identifier, d.DeviceIdentifier,
|
||||||
|
"device must be bound to the claimer, not to every account")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.Token, code)
|
||||||
|
assert.Equal(t, http.StatusConflict, status, "an already-used code must not bind again")
|
||||||
|
assert.Contains(t, resp["error"], "already used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingReRegisterRetiresPriorCode(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
identifier := fmt.Sprintf("pairing-retry-%s", uuid.New().String())
|
||||||
|
|
||||||
|
reg1 := registerPairingDevice(t, setup, identifier)
|
||||||
|
reg2 := registerPairingDevice(t, setup, identifier)
|
||||||
|
|
||||||
|
code1, ok := reg1["pairing_code"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
code2, ok := reg2["pairing_code"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotEqual(t, code1, code2, "each registration mints its own code")
|
||||||
|
|
||||||
|
// The retired attempt is gone from both the code and UUID channels.
|
||||||
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status, "retired code must not claim")
|
||||||
|
pollStatus, _ := pollRegistrationStatus(t, setup, reg1["registration_id"].(string))
|
||||||
|
assert.Equal(t, http.StatusNotFound, pollStatus, "retired registration must not poll")
|
||||||
|
|
||||||
|
// The fresh code claims fine.
|
||||||
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Equal(t, true, resp["approved"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingCancelByRegistrationUUID(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
cancelled := registerPairingDevice(t, setup, fmt.Sprintf("pairing-cancel-%s", uuid.New().String()))
|
||||||
|
kept := registerPairingDevice(t, setup, fmt.Sprintf("pairing-keep-%s", uuid.New().String()))
|
||||||
|
|
||||||
|
// Cancel only the first attempt — bearer is its own registration UUID.
|
||||||
|
body, _ := json.Marshal(map[string]string{"registration_id": cancelled["registration_id"].(string)})
|
||||||
|
req := httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "cancel should succeed")
|
||||||
|
|
||||||
|
// Cancelled: code dead, status poll gone.
|
||||||
|
code1 := cancelled["pairing_code"].(string)
|
||||||
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status, "cancelled code must not claim")
|
||||||
|
pollStatus, _ := pollRegistrationStatus(t, setup, cancelled["registration_id"].(string))
|
||||||
|
assert.Equal(t, http.StatusNotFound, pollStatus, "cancelled registration must not poll")
|
||||||
|
|
||||||
|
// Idempotent: cancelling again is still a 200.
|
||||||
|
req = httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code, "cancel must be idempotent")
|
||||||
|
|
||||||
|
// The other registration is untouched by its neighbour's cancel.
|
||||||
|
code2 := kept["pairing_code"].(string)
|
||||||
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
|
||||||
|
require.Equal(t, http.StatusOK, status, "cancel must scope to its own registration only")
|
||||||
|
assert.Equal(t, true, resp["approved"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingAttemptRateLimit(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
// The regular user is fresh per setup, so the limiter key
|
||||||
|
// (userID|ip) is this test's alone — the budget cannot have been
|
||||||
|
// pre-spent by sibling tests sharing the process-global limiter.
|
||||||
|
// Burn the budget with bad lookups (12/min); the 13th attempt must
|
||||||
|
// be refused before the code check.
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
code := fmt.Sprintf("%06d", i*7919+13)
|
||||||
|
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, code)
|
||||||
|
require.Equal(t, http.StatusBadRequest, status, "attempt %d should fail as a plain miss", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, "999999")
|
||||||
|
assert.Equal(t, http.StatusTooManyRequests, status, "13th attempt must hit the budget")
|
||||||
|
assert.Contains(t, resp["error"], "too many")
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ import (
|
|||||||
|
|
||||||
func TestDeviceRegistrationFlow(t *testing.T) {
|
func TestDeviceRegistrationFlow(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
// Approved through the shared admin's session; the row must not
|
||||||
|
// outlive the test (name hygiene alone should never carry it).
|
||||||
|
cleanupDeviceByIdentifier(t, setup, "kindle-test-hw-id-12345")
|
||||||
|
|
||||||
// Step 1: Initiate device registration
|
// Step 1: Initiate device registration
|
||||||
regRequest := map[string]interface{}{
|
regRequest := map[string]interface{}{
|
||||||
@@ -208,6 +211,7 @@ func TestDeviceAuthentication(t *testing.T) {
|
|||||||
|
|
||||||
// Create a device directly in the database
|
// Create a device directly in the database
|
||||||
userID := getTestUserID(t, setup.DB)
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
cleanupDeviceByIdentifier(t, setup, "test-device-123")
|
||||||
|
|
||||||
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||||
_, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
|
_, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
|
||||||
@@ -237,10 +241,25 @@ func TestDeviceAuthentication(t *testing.T) {
|
|||||||
func TestListPendingRegistrations(t *testing.T) {
|
func TestListPendingRegistrations(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/devices/pending", nil)
|
// A live registration must NOT appear in any account's pending list:
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
// the broadcast is what let any user claim (or reject) someone else's
|
||||||
|
// device. Pairing codes replaced it — see device_pairing_test.go.
|
||||||
|
regRequest := map[string]interface{}{
|
||||||
|
"device_name": "Broadcast Test Device",
|
||||||
|
"device_type": "koreader",
|
||||||
|
"device_identifier": fmt.Sprintf("broadcast-test-%s", uuid.New().String()),
|
||||||
|
}
|
||||||
|
regBody, _ := json.Marshal(regRequest)
|
||||||
|
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/api/devices/pending", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code, "Should list pending registrations")
|
assert.Equal(t, http.StatusOK, rec.Code, "Should list pending registrations")
|
||||||
|
|
||||||
@@ -248,8 +267,9 @@ func TestListPendingRegistrations(t *testing.T) {
|
|||||||
json.Unmarshal(rec.Body.Bytes(), &response)
|
json.Unmarshal(rec.Body.Bytes(), &response)
|
||||||
|
|
||||||
pending, ok := response["pending_registrations"].([]interface{})
|
pending, ok := response["pending_registrations"].([]interface{})
|
||||||
assert.True(t, ok, "Should have pending_registrations array")
|
require.True(t, ok, "Should have pending_registrations array")
|
||||||
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
assert.Empty(t, pending, "Unclaimed registrations must be invisible to every account")
|
||||||
|
assert.Equal(t, float64(0), response["total"], "Total must stay zero while a registration is live")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression test for the "approve returns 500 after app reinstall" bug:
|
// Regression test for the "approve returns 500 after app reinstall" bug:
|
||||||
@@ -263,6 +283,10 @@ func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
|||||||
userID := getTestUserID(t, setup.DB)
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
||||||
|
// Self-delete: the row is named "Reinstall Device" — the web
|
||||||
|
// reinstall flow's own label — so the name-based sweeps can never
|
||||||
|
// safely touch it. The identifier is this test's fixture alone.
|
||||||
|
cleanupDeviceByIdentifier(t, setup, identifier)
|
||||||
|
|
||||||
registerAndApprove := func() string {
|
registerAndApprove := func() string {
|
||||||
regRequest := map[string]interface{}{
|
regRequest := map[string]interface{}{
|
||||||
@@ -336,6 +360,9 @@ func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestApproveDeviceRegistration(t *testing.T) {
|
func TestApproveDeviceRegistration(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
// Approved through the shared admin's session; the row must not
|
||||||
|
// outlive the test (name hygiene alone should never carry it).
|
||||||
|
cleanupDeviceByIdentifier(t, setup, "test-approval-12345")
|
||||||
|
|
||||||
regRequest := map[string]interface{}{
|
regRequest := map[string]interface{}{
|
||||||
"device_name": "Test Device for Approval",
|
"device_name": "Test Device for Approval",
|
||||||
@@ -370,13 +397,18 @@ func TestApproveDeviceRegistration(t *testing.T) {
|
|||||||
assert.True(t, approveResponse["approved"].(bool), "Should confirm approval")
|
assert.True(t, approveResponse["approved"].(bool), "Should confirm approval")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRejectDeviceRegistration(t *testing.T) {
|
// TestRejectDeviceRegistrationRemoved pins the removal: the reject route is
|
||||||
|
// gone because unclaimed registrations are invisible since pairing codes
|
||||||
|
// took over — there is nothing left for anyone to reject. See
|
||||||
|
// device_pairing_test.go for the replacement flow (cancel by the
|
||||||
|
// registering device itself).
|
||||||
|
func TestRejectDeviceRegistrationRemoved(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
regRequest := map[string]interface{}{
|
regRequest := map[string]interface{}{
|
||||||
"device_name": "Test Device for Rejection",
|
"device_name": "Test Device for Rejection",
|
||||||
"device_type": "koreader",
|
"device_type": "koreader",
|
||||||
"device_identifier": "test-rejection-12345",
|
"device_identifier": fmt.Sprintf("test-rejection-%s", uuid.New().String()),
|
||||||
}
|
}
|
||||||
regBody, _ := json.Marshal(regRequest)
|
regBody, _ := json.Marshal(regRequest)
|
||||||
|
|
||||||
@@ -385,25 +417,23 @@ func TestRejectDeviceRegistration(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
|
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
|
||||||
|
|
||||||
var regResponse map[string]interface{}
|
var regResponse map[string]interface{}
|
||||||
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
||||||
|
|
||||||
registrationID, ok := regResponse["registration_id"].(string)
|
registrationID, ok := regResponse["registration_id"].(string)
|
||||||
assert.True(t, ok, "Should have registration_id")
|
require.True(t, ok, "Should have registration_id")
|
||||||
|
|
||||||
req = httptest.NewRequest("POST", fmt.Sprintf("/api/devices/reject/%s", registrationID), nil)
|
req = httptest.NewRequest("POST", fmt.Sprintf("/api/devices/reject/%s", registrationID), nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code, "Should reject device registration")
|
// 404 = no route, 405 = path-shaped collision without POST — either
|
||||||
|
// proves the reject surface is gone.
|
||||||
var rejectResponse map[string]interface{}
|
assert.True(t, rec.Code == http.StatusNotFound || rec.Code == http.StatusMethodNotAllowed,
|
||||||
json.Unmarshal(rec.Body.Bytes(), &rejectResponse)
|
"Reject route must not exist (registration invisible, nothing to reject); got %d", rec.Code)
|
||||||
|
|
||||||
assert.Equal(t, "device registration rejected", rejectResponse["message"], "Should confirm rejection message")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegenerateDeviceToken_Success(t *testing.T) {
|
func TestRegenerateDeviceToken_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -903,3 +903,85 @@ func TestFilterFlatEquivalence(t *testing.T) {
|
|||||||
require.Equal(t, statusFlat, statusTree, "same result status")
|
require.Equal(t, statusFlat, statusTree, "same result status")
|
||||||
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
|
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- deep group nesting (2026-11-01 group ALL/ANY header toggle): ----
|
||||||
|
// the UI can now produce 3+-level mixed-mode trees; pin the engine's
|
||||||
|
// parenthesized evaluation for the user's target structure
|
||||||
|
// GROUP(GROUP(Field and (Field or Field)) or GROUP(Field and Field and
|
||||||
|
// (Field or Field)) and GROUP(Field)) — plus a suggestion request
|
||||||
|
// carrying a nested-any constraint (the mode-aware menu under real
|
||||||
|
// nesting) — must be 200, never the empty-group 400.
|
||||||
|
|
||||||
|
func TestFilterDeepGroupNesting(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Deep")
|
||||||
|
|
||||||
|
titles := func(qf string) []string {
|
||||||
|
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||||
|
status, rows := qfSearch(t, setup, setup.RegularToken, p)
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||||
|
return qfTitles(t, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// root ALL[
|
||||||
|
// ANY[ ALL[fantasy, ANY[>=250p, abridged]] , ALL[Dar, >=500p] ],
|
||||||
|
// author not_contains Quill
|
||||||
|
// ]
|
||||||
|
// => Tide (fantasy + 300p), Wick (Dar + 700p); Fen dies at the root
|
||||||
|
// AND (Quill); Stone fails branch A (history) and branch B (Voss).
|
||||||
|
deep := map[string]interface{}{
|
||||||
|
"mode": "all",
|
||||||
|
"children": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "any",
|
||||||
|
"children": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "all",
|
||||||
|
"children": []interface{}{
|
||||||
|
qfRule("genre", "is", "fantasy"),
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "any",
|
||||||
|
"children": []interface{}{
|
||||||
|
qfRule("page_count", "at_least", "250"),
|
||||||
|
qfRule("abridged", "is_true"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"mode": "all",
|
||||||
|
"children": []interface{}{
|
||||||
|
qfRule("author", "contains", "Dar"),
|
||||||
|
qfRule("page_count", "at_least", "500"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qfRule("author", "not_contains", "Quill"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deepJSON, merr := json.Marshal(deep)
|
||||||
|
require.NoError(t, merr)
|
||||||
|
got := titles(string(deepJSON))
|
||||||
|
assert.ElementsMatch(t, []string{"Deep Tide", "Deep Wick"}, got,
|
||||||
|
"3-level mixed-mode nesting evaluates parenthesized")
|
||||||
|
|
||||||
|
// The same shape riding the SUGGEST endpoint (the mode-aware menu's
|
||||||
|
// constraint) — nested any must be a 200, never the empty-group 400.
|
||||||
|
p := url.Values{
|
||||||
|
"library_id": {libUUID.String()},
|
||||||
|
"author": {""},
|
||||||
|
"limit": {"20"},
|
||||||
|
"qf": {string(deepJSON)},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
||||||
|
"a nested-any constraint rides the suggestion endpoint cleanly: %s", p.Encode())
|
||||||
|
}
|
||||||
@@ -438,7 +438,15 @@ func TestMetadataWriteToFile(t *testing.T) {
|
|||||||
assert.Equal(t, "Comic Series", series)
|
assert.Equal(t, "Comic Series", series)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sweep ALL generations of the fixture dirs, not just this
|
||||||
|
// process's: a killed run never reaches its RemoveAll, and the stale
|
||||||
|
// root-owned dirs then sit on the user's bind mount forever (seven
|
||||||
|
// of them were found in uploads/ in Nov 2026, one per past run —
|
||||||
|
// each surviving run's cleanup skipped them by pid).
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(uploadsDir, "test-metadata-write-*"))
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
|
for _, dir := range matches {
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2520,3 +2520,60 @@ func TestFilterLabelsEndpoint(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
|
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the "?" affordance: the builder links to its manual ----
|
||||||
|
|
||||||
|
// TestFilterHelpAffordance pins the help links (2026-11-01): every
|
||||||
|
// filter-builder mount carries a "?" linking to the manual, and the
|
||||||
|
// manual's FOLDER URL resolves to its index page (the docs loader's
|
||||||
|
// folder-index fallback — the guide is docs/user/filters/index.md).
|
||||||
|
func TestFilterHelpAffordance(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
regToken := setup.RegularToken
|
||||||
|
|
||||||
|
t.Run("docs_folder_url_serves_the_guide", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/docs/user/filters", nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, "the folder URL resolves to index.md; body: %.400s", string(body))
|
||||||
|
assert.Contains(t, string(body), "Filters — the complete guide")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("drawer_carries_help_link", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+regToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(body), `href="/docs/user/filters"`,
|
||||||
|
"the All Books drawer links to the filter guide")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rules_page_carries_help_link", func(t *testing.T) {
|
||||||
|
marker := fmt.Sprintf("helpaff%d", time.Now().UnixNano()%100000)
|
||||||
|
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
|
||||||
|
`{"name":"test HelpAff `+marker+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
|
||||||
|
require.Equal(t, http.StatusCreated, code, "%s", string(body))
|
||||||
|
var coll struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &coll))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+regToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pageBody, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(pageBody), `href="/docs/user/filters"`,
|
||||||
|
"the collection rules editor links to the filter guide")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Resume rewind (per-user server setting): seconds before the saved
|
||||||
|
// position the audiobook/podcast player starts (0 = exact resume).
|
||||||
|
// Contract: the profile endpoint owns reads/writes with a strict
|
||||||
|
// 0/5/10 allow-list (the request shape is the keep_screen_awake string
|
||||||
|
// precedent — the form select must be able to send an explicit Off),
|
||||||
|
// and the chapters payload carries the value so every client (web +
|
||||||
|
// Android, incl. the Auto service) gets it with its existing fetch.
|
||||||
|
// All writes ride the fresh per-test regular user — the shared admin
|
||||||
|
// account is never touched (AGENTS doctrine). Fixture book lives in a
|
||||||
|
// test-named library that cascades away with the library delete.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAudioResumeRewind(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Resume Rewind %d", time.Now().UnixNano()))
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||||
|
|
||||||
|
// 664 Hz tone: not the shared helper's 440 or the speed test's 552,
|
||||||
|
// so the scanner's content dedup never skips this book.
|
||||||
|
writeTestTrackTone(t, filepath.Join(folder, "Rewind Book", "01 one.mp3"), 664)
|
||||||
|
scanTestFolder(t, setup, admin, folder)
|
||||||
|
|
||||||
|
var bookPath string
|
||||||
|
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||||
|
if strings.Contains(filepath.ToSlash(item.FilePath), "Rewind Book") && bookPath == "" {
|
||||||
|
bookPath = item.FilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, bookPath, "Rewind Book item must exist")
|
||||||
|
book, found := findAudioItemByPath(t, setup, libID, bookPath)
|
||||||
|
require.True(t, found)
|
||||||
|
bookID := uuid.UUID(book.ID.Bytes)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
token := setup.RegularToken
|
||||||
|
authed := func(method, url, body string) (*http.Response, func()) {
|
||||||
|
var req *http.Request
|
||||||
|
if body == "" {
|
||||||
|
req, _ = http.NewRequest(method, url, nil)
|
||||||
|
} else {
|
||||||
|
req, _ = http.NewRequest(method, url, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, func() { _ = resp.Body.Close() }
|
||||||
|
}
|
||||||
|
getPayload := func(itemID uuid.UUID) (rewind int) {
|
||||||
|
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var data struct {
|
||||||
|
ResumeRewindSeconds int `json:"resume_rewind_seconds"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
|
||||||
|
return data.ResumeRewindSeconds
|
||||||
|
}
|
||||||
|
getProfileRewind := func() int {
|
||||||
|
resp, closer := authed("GET", setup.Server.URL+"/api/auth/profile", "")
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var profile struct {
|
||||||
|
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
|
||||||
|
return profile.AudioResumeRewind
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("defaults to off (exact resume)", func(t *testing.T) {
|
||||||
|
require.Equal(t, 0, getProfileRewind())
|
||||||
|
require.Equal(t, 0, getPayload(bookID))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile PUT selects 5 and rides the chapters payload", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"5"}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 5, getProfileRewind())
|
||||||
|
require.Equal(t, 5, getPayload(bookID))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-allow-listed values are rejected and change nothing", func(t *testing.T) {
|
||||||
|
for _, body := range []string{`{"audio_resume_rewind_seconds":"7"}`, `{"audio_resume_rewind_seconds":"-5"}`, `{"audio_resume_rewind_seconds":"abc"}`} {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", body)
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
closer()
|
||||||
|
}
|
||||||
|
require.Equal(t, 5, getProfileRewind())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit off resets to 0 everywhere", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"0"}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 0, getProfileRewind())
|
||||||
|
require.Equal(t, 0, getPayload(bookID))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON clients that omit the key leave it untouched", func(t *testing.T) {
|
||||||
|
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"timezone":"UTC"}`)
|
||||||
|
defer closer()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 0, getProfileRewind())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -96,6 +96,43 @@ type TestServerSetup struct {
|
|||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupDeviceByIdentifier deletes any devices rows carrying the given
|
||||||
|
// identifier at test end. API-register tests need this instead of a
|
||||||
|
// token-based delete: the status poll is single-use, so a row created
|
||||||
|
// through register+approve changes tokens mid-test — the stable
|
||||||
|
// identity is UNIQUE(device_identifier). Use for every device a test
|
||||||
|
// attaches to the SHARED admin (testuser); See the DOCTRINE comment in
|
||||||
|
// setupTestServer — rows that survive their test pile up on the live
|
||||||
|
// dev account's device grid.
|
||||||
|
func cleanupDeviceByIdentifier(t *testing.T, setup *TestServerSetup, identifier string) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = setup.DBPool.Exec(context.Background(),
|
||||||
|
"DELETE FROM devices WHERE device_identifier = $1", identifier)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupCollectionByID deletes one collections row at test end, scoped
|
||||||
|
// through the RLS bridge to its owner — collections RLS (2026-10-31)
|
||||||
|
// makes unscoped writes invisible (no-op). Use for a collection a test
|
||||||
|
// attaches to the shared admin whose name CANNOT carry the "test"
|
||||||
|
// marker the setup-time name sweep keys on (uuid-suffixed uniqueness
|
||||||
|
// against stale UNIQUE(user_id, name) rows from past runs). Rows that
|
||||||
|
// survive their test also get stranded forever — the 10 accumulated
|
||||||
|
// "Admin Collection <uuid>" rows were exactly this.
|
||||||
|
func cleanupCollectionByID(t *testing.T, setup *TestServerSetup, ownerID [16]byte, collectionID string) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cid, err := uuid.Parse(collectionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("cleanupCollectionByID: unparsable id %q — %v", collectionID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope := rls.WithUser(context.Background(), pgtype.UUID{Bytes: ownerID, Valid: true})
|
||||||
|
if err := setup.DB.DeleteCollection(scope, pgtype.UUID{Bytes: cid, Valid: true}); err != nil {
|
||||||
|
t.Logf("cleanupCollectionByID: delete failed for %s — %v", collectionID, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Close cleans up all resources in the correct order
|
// Close cleans up all resources in the correct order
|
||||||
func (s *TestServerSetup) Close() error {
|
func (s *TestServerSetup) Close() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -481,6 +518,13 @@ func (s *TestDeviceSetup) CreateDevice(t *testing.T, deviceName, deviceType, dev
|
|||||||
})
|
})
|
||||||
require.NoError(t, err, "Should create device")
|
require.NoError(t, err, "Should create device")
|
||||||
|
|
||||||
|
// Devices created through this helper self-delete at test end, so
|
||||||
|
// callers owe nothing to name hygiene: setupDeviceTest attaches to
|
||||||
|
// the SHARED admin, which the harness deliberately never deletes —
|
||||||
|
// unqualified rows would pile up on the live dev account forever
|
||||||
|
// (the "OPDS Folders Device" graveyard was exactly this gap).
|
||||||
|
t.Cleanup(func() { _ = s.DB.DeleteDevice(context.Background(), device.ID) })
|
||||||
|
|
||||||
deviceUUID, err := uuid.FromBytes(device.ID.Bytes[0:16])
|
deviceUUID, err := uuid.FromBytes(device.ID.Bytes[0:16])
|
||||||
require.NoError(t, err, "Should parse device ID")
|
require.NoError(t, err, "Should parse device ID")
|
||||||
|
|
||||||
@@ -809,9 +853,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// Same passes for test DEVICES (identifier/name containing "test")
|
// Same passes for test DEVICES (identifier/name containing "test")
|
||||||
// and test SAVED FILTERS (name containing "test"): both accumulated
|
// and test SAVED FILTERS (name containing "test"): both accumulated
|
||||||
// across runs and poisoned later runs (duplicate identifiers, 409s,
|
// across runs and poisoned later runs (duplicate identifiers, 409s,
|
||||||
// non-empty "initially empty" assertions).
|
// non-empty "initially empty" assertions). The second half of the
|
||||||
|
// device predicate catches test suites that attach to the shared
|
||||||
|
// admin with surface-specific names ("koreader" from the contract
|
||||||
|
// pins, "OPDS Folders Device", "Reinstall Device") — every suite
|
||||||
|
// now also self-deletes its rows, this is the drift safety net:
|
||||||
|
// dev_contract_% auth tokens, contract-% / opds-folders-% /
|
||||||
|
// repro-reinstall-% identifiers are unambiguously harness-owned.
|
||||||
_, derr := dbPool.Exec(ctx,
|
_, derr := dbPool.Exec(ctx,
|
||||||
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
|
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%' "+
|
||||||
|
"OR device_identifier ILIKE 'contract-%' OR device_identifier ILIKE 'opds-folders-%' OR device_identifier ILIKE 'repro-reinstall-%' "+
|
||||||
|
"OR auth_token LIKE 'dev_contract_%')",
|
||||||
adminUUID)
|
adminUUID)
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
t.Logf("device cleanup failed: %v", derr)
|
t.Logf("device cleanup failed: %v", derr)
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
|||||||
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
collectionID := collectionResult["id"].(string)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
// The uuid suffix keeps the name off every run's UNIQUE(user_id,name),
|
||||||
|
// which also keeps it out of the name-based cleanup sweep — self-delete
|
||||||
|
// instead (owner-scoped, exactly who created it).
|
||||||
|
cleanupCollectionByID(t, setup, getTestUserID(t, setup.DB), collectionID)
|
||||||
|
|
||||||
// Create a test book via API
|
// Create a test book via API
|
||||||
bookID := createTestMediaItemID(t, setup)
|
bookID := createTestMediaItemID(t, setup)
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEF
|
|||||||
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
|
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Audio resume rewind: seconds before the saved position the
|
||||||
|
-- audiobook/podcast player starts (on open-resume AND again on every
|
||||||
|
-- genuine unpause — the re-orientation gap, never stacked). Strict
|
||||||
|
-- allow-list 0/5/10 enforced handler-side; 0 = exact resume. The
|
||||||
|
-- profile page's dropdown owns it; the chapters payload carries it to
|
||||||
|
-- every client (web + Android, incl. the car path).
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS audio_resume_rewind_seconds INT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
-- Create system_settings table
|
-- Create system_settings table
|
||||||
CREATE TABLE IF NOT EXISTS system_settings (
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!-- Ground truth: the Administration section itself (docs/admin/), the app
|
||||||
|
sidebar's Administration group (templates/header.templ: Dashboard,
|
||||||
|
Libraries, Podcasts, Hash Conflicts, Users, Settings), and the
|
||||||
|
per-page ground-truth comments in this directory. This page is a
|
||||||
|
directory; the pages it links to carry their own source pins. -->
|
||||||
|
|
||||||
|
# Admin Guide
|
||||||
|
|
||||||
|
Everything an instance administrator runs lives in this section. Like the
|
||||||
|
rest of Bookhoard's docs, pages tell you what to click — but admin pages
|
||||||
|
add the layer users don't need: **what each control actually does** before
|
||||||
|
you click it, what it costs, and what it changes. (The calibration, if
|
||||||
|
you're curious: *user docs = what you see, admin docs = what it does,
|
||||||
|
developer docs = how it works*.)
|
||||||
|
|
||||||
|
## The Administration section
|
||||||
|
|
||||||
|
| Page | What it covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [Dashboard](dashboard.md) | The `/admin` landing page: stats, file-watcher status, **Force Rescan All Libraries**, live scan progress |
|
||||||
|
| [Libraries](libraries.md) | Creating libraries, folders, per-user visibility, per-library scans, deletion semantics |
|
||||||
|
| [Processing Issues](library-processing-issues.md) | Per-library ledger of items the scanner couldn't process — Retry, Retry all, Dismiss |
|
||||||
|
| [Archived Items](archived-items.md) | The missing-file lifecycle: two-strikes archiving, Merge/Restore/Delete, retention |
|
||||||
|
| [Podcasts](podcasts.md) | The all-feeds maintenance view: disk usage, refresh, delete files, remove for everyone |
|
||||||
|
| [Hash Conflicts](hash-conflicts.md) | Byte-identical duplicates and their resolution (merge reading data or keep both) |
|
||||||
|
| [Users](users.md) | Accounts, roles, password resets, the last-admin guards |
|
||||||
|
| [Settings](settings.md) | Every server-wide setting: key, default, range, what it does, restart or live |
|
||||||
|
| [Scanning](scanning.md) | The deep-dive: shallow vs. force, and what every scan button actually does |
|
||||||
|
|
||||||
|
The app's own sidebar shows the first six as the **Administration** group;
|
||||||
|
Processing Issues and Archived Items hang off the Libraries page.
|
||||||
|
|
||||||
|
## If you're setting up a fresh instance
|
||||||
|
|
||||||
|
The shortest sane path, in order:
|
||||||
|
|
||||||
|
1. **[Libraries](libraries.md)** → Create Library, add its folders
|
||||||
|
2. Let the initial scan populate it (or **Force Rescan** to be sure)
|
||||||
|
3. **[Settings](settings.md)** → set the **Base URL** to the address other
|
||||||
|
devices will use, and check **Auto-Scan** is on
|
||||||
|
4. **[Users](users.md)** → create accounts (or leave public registration
|
||||||
|
on), set roles
|
||||||
|
5. Point devices at the server — the [device guides](../user/user-guide.md)
|
||||||
|
walk each one
|
||||||
|
|
||||||
|
## When something looks wrong
|
||||||
|
|
||||||
|
- Books vanished from the library? [Archived Items](archived-items.md) —
|
||||||
|
they're almost certainly there, history intact.
|
||||||
|
- A book's metadata is stale or wrong? Open the book → **Rescan**
|
||||||
|
(re-extracts that one item, sidecars included).
|
||||||
|
- Duplicates? [Hash Conflicts](hash-conflicts.md).
|
||||||
|
- A scan didn't pick up your changes? [Scanning](scanning.md) explains
|
||||||
|
which scans skip unchanged files — and which never do.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!-- Ground truth: templates/admin_archived.templ (page copy, Merge/
|
||||||
|
Restore/Delete row actions, Purge All Archived, retention sentence),
|
||||||
|
internal/config/config.go (ARCHIVE_RETENTION_DAYS, default 90, 0 =
|
||||||
|
manual purge only), internal/handlers/media.go (MergeArchivedMediaItem
|
||||||
|
— reading progress/annotations/collections merge, per-user collisions
|
||||||
|
keep the active copy), templates/admin_library.templ (entry banner).
|
||||||
|
Re-verify against these when the page changes. -->
|
||||||
|
|
||||||
|
# Archived Items
|
||||||
|
|
||||||
|
Archived items are books whose files went missing from disk. Bookhoard
|
||||||
|
doesn't delete them on the first absence: a library-wide scan that can't
|
||||||
|
find a file marks the item missing, and only when the file is still absent
|
||||||
|
for **two consecutive scans** does the item move to the archive. Until
|
||||||
|
then — or after — the reader's history is preserved, so a file that comes
|
||||||
|
back (or a move the scanner detects automatically) restores the item with
|
||||||
|
its reading data intact.
|
||||||
|
|
||||||
|
You reach this page from the [Libraries](libraries.md) page, which shows
|
||||||
|
an **Archived items** banner with the count whenever anything is archived.
|
||||||
|
|
||||||
|
## What the page shows
|
||||||
|
|
||||||
|
A table of every archived item: book title and author, the file path that
|
||||||
|
went missing, its library, and a status column. When the list is empty you
|
||||||
|
get the good news instead: "Nothing is archived or missing. All clear."
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
By default archived items are **permanently deleted after 90 days**
|
||||||
|
(configured with the `ARCHIVE_RETENTION_DAYS` environment variable; set it
|
||||||
|
to `0` to keep archived items until you purge them by hand). The page
|
||||||
|
header states your instance's current rule.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
| Action | What it actually does |
|
||||||
|
| --- | --- |
|
||||||
|
| **Merge** (only when a matching active book exists) | Folds this archived copy's reading data — progress, annotations, collections — into the active copy and removes the archived entry. If the same user has reading data on both copies, the active copy's data wins. This is the right choice when the duplicate is a leftover twin and the real book lives on. |
|
||||||
|
| **Restore** | Puts the item back into the libraries immediately. If the file is genuinely still gone, the next scan archives it again — useful when you know the file just came back or is about to. |
|
||||||
|
| **Delete** | Deletes the item **and its reading history** permanently. No archive, no undo. |
|
||||||
|
|
||||||
|
### Purge All Archived
|
||||||
|
|
||||||
|
The header button wipes every archived item at once — permanently, reading
|
||||||
|
history included (a confirm dialog says exactly that). It exists for the
|
||||||
|
"clean slate" moments; per-item **Delete** is the scalpel.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Scanning](scanning.md) — how scans decide what's missing, and why only
|
||||||
|
library-wide scans (shallow or force) archive; a single-folder scan
|
||||||
|
never marks its sibling folders' items missing.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!-- Ground truth: templates/admin.templ (stats grid, File Watcher card,
|
||||||
|
Quick Actions, Scanning panel), internal/router/frontend.go (/admin
|
||||||
|
handler), internal/router/admin_library.go (getAdminStats),
|
||||||
|
web/src/admin.ts (scanAllLibraries one-job-per-library, watch status
|
||||||
|
fetch, progress rows), internal/handlers/scanner.go (watch status,
|
||||||
|
coalescing 409). Re-verify against these when the page changes. -->
|
||||||
|
|
||||||
|
# Admin Dashboard
|
||||||
|
|
||||||
|
The dashboard at `/admin` is the admin landing page: a four-number health
|
||||||
|
check, the file-watcher status, and the place where library-wide scans are
|
||||||
|
launched and watched.
|
||||||
|
|
||||||
|
## The stats grid
|
||||||
|
|
||||||
|
Four counts, straight from the database, refreshed on every page load:
|
||||||
|
|
||||||
|
| Card | What the number counts |
|
||||||
|
| --- | --- |
|
||||||
|
| **Libraries** | Every library — book, comic, audiobook, and podcast libraries alike |
|
||||||
|
| **Books** | Every item across all of them: books, comics, audiobooks, and podcast episodes |
|
||||||
|
| **Users** | Every account on the instance |
|
||||||
|
| **Devices** | Every registered sync device (Android, KOReader, Kobo) |
|
||||||
|
|
||||||
|
These are instance-wide totals — they don't filter by what any individual
|
||||||
|
user can see.
|
||||||
|
|
||||||
|
## File Watcher
|
||||||
|
|
||||||
|
The **File Watcher** card shows how many libraries are currently under
|
||||||
|
live watch for file changes. The watcher exists when **Auto-Scan** is
|
||||||
|
enabled on the Settings page; the number updates on page load.
|
||||||
|
|
||||||
|
- Watching **0** libraries usually means auto-scan is off — turn it on in
|
||||||
|
[Settings](settings.md) → Scanning, and the watcher arms on the next
|
||||||
|
server start.
|
||||||
|
- Watched libraries get shallow scans as files change, plus a periodic
|
||||||
|
poll on the scan interval — see [Scanning](scanning.md) for exactly what
|
||||||
|
those do.
|
||||||
|
|
||||||
|
## Quick Actions
|
||||||
|
|
||||||
|
### Force Rescan All Libraries
|
||||||
|
|
||||||
|
The big one. This is a **force rescan of every library**: each file is
|
||||||
|
re-read and re-extracted with the scanner's current parsing, and hash
|
||||||
|
identifiers are recomputed — nothing unchanged is skipped. Use it as the
|
||||||
|
backfill tool after mass edits or scanner improvements; don't use it as a
|
||||||
|
routine refresh (that's what the watcher and [per-library
|
||||||
|
scans](scanning.md) are for, which skip unchanged files).
|
||||||
|
|
||||||
|
Mechanically it starts **one scan job per library** and reports per
|
||||||
|
library: started, or skipped because that library already had a scan
|
||||||
|
running (only one scan per library can run at a time).
|
||||||
|
|
||||||
|
### Manage Libraries
|
||||||
|
|
||||||
|
A shortcut to [Libraries](libraries.md), where folders, access, and
|
||||||
|
per-library scans live.
|
||||||
|
|
||||||
|
## The Scanning panel
|
||||||
|
|
||||||
|
Whenever scans are running, a **Scanning Libraries** panel appears with a
|
||||||
|
live progress row per library (progress bar, current file counts, and a
|
||||||
|
per-library cancel button), plus:
|
||||||
|
|
||||||
|
- **Cancel all scans** — stops every queued or running scan job
|
||||||
|
- The close button — hides the panel; scans keep running without it
|
||||||
|
|
||||||
|
The panel appears for any scan — the dashboard's own force rescan, or one
|
||||||
|
started from the Libraries page. Progress updates live while the page is
|
||||||
|
open; the jobs themselves run on the server either way.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!-- Ground truth: templates/admin_hash_conflicts.templ (cards, actions,
|
||||||
|
confirms), internal/handlers/hash_conflicts.go (ResolveHashConflict:
|
||||||
|
keep_all marks resolved; keep reparents children to the kept copy,
|
||||||
|
deletes the other catalog rows, records who resolved), internal/
|
||||||
|
services/media_scanner.go (recordHashConflictIfAny), internal/services/
|
||||||
|
hash_backfill.go (backfill-created conflicts). Re-verify against these
|
||||||
|
when the page changes. -->
|
||||||
|
|
||||||
|
# Hash Conflicts
|
||||||
|
|
||||||
|
Hash Conflicts is the duplicate-content queue: groups of catalog items
|
||||||
|
whose files are **byte-identical** (same SHA-256) but stored at more than
|
||||||
|
one path in the same library. The scanner flags these as it goes — and
|
||||||
|
the hash backfill job flags them retroactively for content that predates
|
||||||
|
hash tracking — so both fresh scans and old libraries surface their
|
||||||
|
duplicates here.
|
||||||
|
|
||||||
|
Duplicates usually mean the same book was copied into two folders, or a
|
||||||
|
library was reorganized and both the old and new copies got scanned. The
|
||||||
|
harm is quiet but real: split reading histories, double-counted library
|
||||||
|
stats, and two entries for one book.
|
||||||
|
|
||||||
|
## What a conflict looks like
|
||||||
|
|
||||||
|
One card per group: a **Duplicate content** heading, a badge with the
|
||||||
|
copy count, the library and a short fingerprint of the shared hash, and
|
||||||
|
one row per copy — title, author, full path, file size, and a usage
|
||||||
|
summary that's **highlighted when that copy holds reading data** (someone
|
||||||
|
has actually read it).
|
||||||
|
|
||||||
|
## Resolving a group
|
||||||
|
|
||||||
|
Two outcomes, both confirm-guarded:
|
||||||
|
|
||||||
|
### Keep this copy
|
||||||
|
|
||||||
|
Per-copy button. Keeps the copy you chose and folds the **other copies'
|
||||||
|
reading data into it** (their catalog rows are removed; the underlying
|
||||||
|
files on disk are never touched — this platform's deletes act on the
|
||||||
|
catalog, not your files). The row-level usage summaries exist precisely
|
||||||
|
so you can pick the copy that already carries the reading history.
|
||||||
|
|
||||||
|
### Keep both
|
||||||
|
|
||||||
|
The group-level button, for duplicates that are intentional. It records
|
||||||
|
the resolution and stops flagging this group — every copy stays in the
|
||||||
|
catalog exactly as it is, nothing merged, nothing removed.
|
||||||
|
|
||||||
|
After either choice the card is replaced with the outcome, and the group
|
||||||
|
never comes back (unless the same duplicate situation re-arises from a
|
||||||
|
*new* scan, which creates a fresh group).
|
||||||
|
|
||||||
|
## When the page is empty
|
||||||
|
|
||||||
|
"No content duplicates detected." — the queue drains from the top; groups
|
||||||
|
appear as scans and the backfill find them.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!-- Ground truth: templates/admin_library.templ (page, modals, panel,
|
||||||
|
delete-library warnings), internal/router/admin_library.go (create/
|
||||||
|
edit/delete, folders, browse, visibility endpoints), internal/router/
|
||||||
|
library.go (per-library scan route), internal/services/library_service.go
|
||||||
|
(DeleteLibraryFolder removes the mapping only; SetLibraryVisibility),
|
||||||
|
templates/utils.go (hxValsFolderScan), templates/admin_archived.templ.
|
||||||
|
Re-verify against these when the page changes. -->
|
||||||
|
|
||||||
|
# Libraries
|
||||||
|
|
||||||
|
The Libraries page is where a Bookhoard instance is defined: what
|
||||||
|
libraries exist, which folders on the server they read from, who can see
|
||||||
|
them, and how they get scanned. Everything here is admin-only — regular
|
||||||
|
users just see the result.
|
||||||
|
|
||||||
|
## Creating a library
|
||||||
|
|
||||||
|
**Create Library** opens a modal with three fields:
|
||||||
|
|
||||||
|
- **Library Name** (required) — what users see in their sidebar
|
||||||
|
- **Description** (optional)
|
||||||
|
- **Library Type** (required) — book, comic, audiobook, podcast; the type
|
||||||
|
drives how the scanner parses files and how the reader presents them
|
||||||
|
|
||||||
|
A library with no folders can't scan — add at least one folder (below)
|
||||||
|
before expecting anything to appear in it.
|
||||||
|
|
||||||
|
## The library card
|
||||||
|
|
||||||
|
Each library renders as a card: name, description, a type chip, a folder
|
||||||
|
count, and two controls:
|
||||||
|
|
||||||
|
- **Force Rescan** — starts a force scan of this one library: every file
|
||||||
|
re-read and re-extracted, hashes recomputed. This is the heavy,
|
||||||
|
thorough pass ([Scanning](scanning.md) explains force vs. shallow and
|
||||||
|
when each is worth it). If a scan is already running for the library,
|
||||||
|
the click tells you instead of stacking a second one.
|
||||||
|
- **Manage** — opens the library's panel, where everything else lives.
|
||||||
|
|
||||||
|
## The library panel
|
||||||
|
|
||||||
|
### Folders
|
||||||
|
|
||||||
|
Folders are the server-side directories the scanner walks. Each one shows
|
||||||
|
its path with two icon actions: **Scan this folder** (a shallow scan
|
||||||
|
scoped to just that folder's subtree — the quick "I just dropped files
|
||||||
|
here" button) and remove-from-library (confirm-guarded).
|
||||||
|
|
||||||
|
Adding one: type a path, or click **Browse** to walk the server's
|
||||||
|
filesystem in a modal (up with `..`, into a folder by clicking it,
|
||||||
|
**Select This Folder** to fill the input), then **Add**.
|
||||||
|
|
||||||
|
Two things worth knowing:
|
||||||
|
|
||||||
|
- **Removing a folder only unregisters it.** The mapping is deleted;
|
||||||
|
files on disk and existing catalog items are untouched. If no remaining
|
||||||
|
folder covers those files, later library-wide scans will treat them as
|
||||||
|
missing — the [Archived Items](archived-items.md) lifecycle takes over,
|
||||||
|
history kept.
|
||||||
|
- **Podcast download folders are feed-managed.** The scanner refuses to
|
||||||
|
scan them by hand; the podcast feed system owns their contents.
|
||||||
|
|
||||||
|
### User Access
|
||||||
|
|
||||||
|
One checkbox per user. Checked (the default) means the library is visible
|
||||||
|
to them; unchecking hides it from their browsing, apps, and OPDS —
|
||||||
|
enforced in the database, so every listing obeys. Hiding a library never
|
||||||
|
destroys anything: the user simply can't see it.
|
||||||
|
|
||||||
|
### Processing issues
|
||||||
|
|
||||||
|
When the library has items the scanner couldn't process, a link here
|
||||||
|
shows the count and leads to its
|
||||||
|
[Processing Issues](library-processing-issues.md) page.
|
||||||
|
|
||||||
|
### Edit Details and Delete Library
|
||||||
|
|
||||||
|
**Edit Details** changes the name and description. The library type is
|
||||||
|
fixed at creation — it can't be edited later.
|
||||||
|
|
||||||
|
**Delete Library** is the destructive one, and its confirm dialog says
|
||||||
|
precisely what it does:
|
||||||
|
|
||||||
|
- **Permanently removed, no archive or undo:** the library, its folder
|
||||||
|
mappings, and all book records — with their reading progress,
|
||||||
|
highlights, notes, bookmarks, and ratings.
|
||||||
|
- **Not touched:** media files on disk. Delete the library, keep the
|
||||||
|
files; re-create the library later and scan them fresh (with empty
|
||||||
|
reading history).
|
||||||
|
|
||||||
|
## The archived banner
|
||||||
|
|
||||||
|
When any library has archived (missing-file) items, this page shows a
|
||||||
|
banner with the total count and two shortcuts: **View Archived** (the
|
||||||
|
[Archived Items](archived-items.md) page) and **Purge Archived Now** (the
|
||||||
|
same permanent wipe the archived page's Purge All Archived performs).
|
||||||
|
|
||||||
|
## Every scan entry point, at a glance
|
||||||
|
|
||||||
|
| Where | Control | Kind |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| This page, library card | **Force Rescan** | Force, this library |
|
||||||
|
| This page, folder row | **Scan this folder** | Shallow, that folder |
|
||||||
|
| Admin dashboard | **Force Rescan All Libraries** | Force, everything |
|
||||||
|
| Settings → Scanning | **Auto-Scan** | Shallow, automatic |
|
||||||
|
| A book's page | **Rescan** | Force, that one item |
|
||||||
|
|
||||||
|
Full semantics for each: [Scanning](scanning.md).
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!-- Ground truth: templates/admin_processing_issues.templ (stats cards,
|
||||||
|
Retry all queue, per-issue Retry/Dismiss, severity badges),
|
||||||
|
internal/handlers/processing_issues.go (RetryProcessingIssue — success
|
||||||
|
closes the issue, failure returns the real error; resolve path),
|
||||||
|
internal/router/library.go (issue routes), the deliberately-not-a-
|
||||||
|
library-rescan design comment in the template. Re-verify against these
|
||||||
|
when the page changes. -->
|
||||||
|
|
||||||
|
# Processing Issues
|
||||||
|
|
||||||
|
Processing Issues is each library's ledger of items the scanner could not
|
||||||
|
fully process — a corrupt file, an unsupported format, metadata that
|
||||||
|
wouldn't parse. It is per-library: you reach it from that library's panel
|
||||||
|
on the [Libraries](libraries.md) page (the link only appears when the
|
||||||
|
library has issues).
|
||||||
|
|
||||||
|
## What's on the page
|
||||||
|
|
||||||
|
Three count cards — **Errors**, **Warnings**, **Info** — show how many
|
||||||
|
open issues sit at each severity (a card only appears when that severity
|
||||||
|
has issues). Below them, one card per issue showing:
|
||||||
|
|
||||||
|
- the item's **title** and a plain-language **description** of what went
|
||||||
|
wrong
|
||||||
|
- **Type**, **Format**, **File** (full path), and **Library** type
|
||||||
|
- a severity badge: red for errors, amber for warnings, neutral for info
|
||||||
|
|
||||||
|
## Fixing an issue
|
||||||
|
|
||||||
|
### Retry
|
||||||
|
|
||||||
|
Runs that one item back through the scanner. This is a per-item scan —
|
||||||
|
the item's file is re-read and re-extracted exactly like a book-page
|
||||||
|
[Rescan](scanning.md), not a pass over the library. If it succeeds, the
|
||||||
|
issue closes itself; if it fails again, the page shows the *real* error,
|
||||||
|
so you know why it still fails instead of staring at a stale complaint.
|
||||||
|
|
||||||
|
### Retry all
|
||||||
|
|
||||||
|
Queues every issue currently on the page and works through them **one at
|
||||||
|
a time** — the button shows progress as "done / total" while the queue
|
||||||
|
drains. This is deliberately not a whole-library scan: fixing a handful of
|
||||||
|
bad files never re-reads the good ones. Per-issue **Retry** buttons are
|
||||||
|
disabled while the queue is running.
|
||||||
|
|
||||||
|
When the queue finishes, the page refreshes itself from the server —
|
||||||
|
closed issues disappear, still-failing ones stay with fresh error text.
|
||||||
|
|
||||||
|
### Dismiss
|
||||||
|
|
||||||
|
Closes an issue without re-processing — for warnings and info items you've
|
||||||
|
looked at and judged fine (a confirm dialog guards the click). **Errors
|
||||||
|
cannot be dismissed**: they must be fixed and successfully retried. The
|
||||||
|
Dismiss button simply doesn't exist on an error card.
|
||||||
|
|
||||||
|
## How issues get here and leave
|
||||||
|
|
||||||
|
The scanner creates an issue whenever it fails to process an item during a
|
||||||
|
scan. Issues leave the page three ways: a successful **Retry** (or any
|
||||||
|
later scan that processes the item cleanly), a **Dismiss** you chose for a
|
||||||
|
warning or info item, or the file's problem being fixed on disk and the
|
||||||
|
next scan handling the item cleanly.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!-- Ground truth: templates/admin_podcasts.templ (table, actions, orphaned
|
||||||
|
badge, header totals, design comment: sorted by disk usage), internal/
|
||||||
|
router/frontend.go (/admin/podcasts handler), podcast polling/download
|
||||||
|
settings in internal/database/settings_registry.go (SettingsDefaults,
|
||||||
|
Podcasts group). Re-verify against these when the page changes. -->
|
||||||
|
|
||||||
|
# All Podcasts
|
||||||
|
|
||||||
|
This is the maintenance view for the podcast subsystem: **every feed from
|
||||||
|
every user**, sorted by disk usage, so the biggest disk hogs surface
|
||||||
|
first. (The listening side — subscribing, playing, downloading episodes —
|
||||||
|
lives in the [user podcast guide](../user/podcasts.md); this page is
|
||||||
|
about running the thing.)
|
||||||
|
|
||||||
|
## The header totals
|
||||||
|
|
||||||
|
Above the table: how many feeds exist, how many episodes are downloaded
|
||||||
|
versus total, and how much disk the downloads occupy instance-wide.
|
||||||
|
|
||||||
|
## The table
|
||||||
|
|
||||||
|
One row per feed:
|
||||||
|
|
||||||
|
| Column | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| **Feed** | Title (links to its podcast page) and host |
|
||||||
|
| **Library** | Which library holds it |
|
||||||
|
| **Subscribers** | How many users subscribe (hover for names). **orphaned** means nobody does — usually a departed user's feed |
|
||||||
|
| **Downloaded** | Episodes on disk / episodes the feed has published |
|
||||||
|
| **Disk** | Space the downloaded files occupy |
|
||||||
|
| **Last fetch** | When the feed was last checked for new episodes |
|
||||||
|
|
||||||
|
## Actions, per feed
|
||||||
|
|
||||||
|
- **Refresh** — checks the feed for new episodes right now, instead of
|
||||||
|
waiting for the automatic poll (its interval lives in Settings →
|
||||||
|
Podcasts).
|
||||||
|
- **Delete files** (only when episodes are downloaded) — frees the disk
|
||||||
|
and resets those episodes to *pending*. They re-download on demand when
|
||||||
|
someone plays them. The confirm says the important part: **a feed
|
||||||
|
refresh will not re-materialize them** — deleting files is safe and
|
||||||
|
reversible by playback, not by refresh.
|
||||||
|
- **Remove** — the admin delete: removes the feed, its episode rows, its
|
||||||
|
downloaded files, and everyone's subscriptions. This is instance-wide
|
||||||
|
(the confirm says so); a user removing their own subscription from the
|
||||||
|
listening side only affects that user.
|
||||||
|
|
||||||
|
## When the page is empty
|
||||||
|
|
||||||
|
"No podcast feeds exist yet." — feeds appear when users add them (or when
|
||||||
|
you do, from the podcast section of the app).
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!-- Ground truth: internal/handlers/scanner.go (ScanLibrary, StartScanner,
|
||||||
|
per-folder validation, coalescing), internal/services/media_scanner.go
|
||||||
|
(forceRescan semantics, steady-skip path, watcher/poll/initial
|
||||||
|
triggers, enqueueLibraryScan force:false), internal/services/worker.go
|
||||||
|
(SetForce), templates/admin.templ + web/src/admin.ts (dashboard
|
||||||
|
button, one job per library), templates/admin_library.templ +
|
||||||
|
templates/utils.go (per-library Scan force:true, per-folder scan),
|
||||||
|
templates/admin_settings.templ (Auto-Scan, Scan Interval).
|
||||||
|
Re-verify against these when scanning behavior changes. -->
|
||||||
|
|
||||||
|
# Scanning
|
||||||
|
|
||||||
|
Scanning is how Bookhoard's server reads your library folders and keeps the
|
||||||
|
catalog in step with the files on disk. This page covers every scan an
|
||||||
|
admin can trigger, what each one actually does, and what it costs — so you
|
||||||
|
can pick the right one before clicking.
|
||||||
|
|
||||||
|
## The one distinction: shallow vs. force
|
||||||
|
|
||||||
|
Every scan is one of two kinds:
|
||||||
|
|
||||||
|
- **Shallow** (the default). The scanner walks the folder and skips any
|
||||||
|
file whose size and modification time are unchanged since it was last
|
||||||
|
seen. New and changed files get processed; untouched files cost nothing.
|
||||||
|
Fast, and the right choice for day-to-day keeping-up.
|
||||||
|
- **Force**. Nothing is skipped. Every file is re-read and re-extracted
|
||||||
|
with the scanner's current parsing, and each item's hash identifiers
|
||||||
|
(the SHA-256 fingerprints used for duplicate detection and device book
|
||||||
|
matching) are recomputed. This is the backfill tool: run it after
|
||||||
|
scanner improvements you want applied to already-scanned books, after
|
||||||
|
mass edits in Calibre or on disk, or when metadata looks stale anywhere
|
||||||
|
in a library. It is deliberately slow on big libraries.
|
||||||
|
|
||||||
|
## Every scan control, and what it does
|
||||||
|
|
||||||
|
| Control | Where | Kind | Scope |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Force Rescan All Libraries** | Admin dashboard → Quick Actions | Force | Every library |
|
||||||
|
| **Force Rescan** (library row) | Admin → Libraries | Force | That library |
|
||||||
|
| **Scan this folder** (folder-row icon) | Admin → Libraries → a library's panel | Shallow | That folder's subtree |
|
||||||
|
| **Auto-Scan** + **Scan Interval (seconds)** | Admin → Settings → Scanning | Shallow | Every library |
|
||||||
|
| **Retry** / **Retry all** | Admin → Libraries → Processing Issues | Per-item | Failed items only |
|
||||||
|
| **Rescan** / **Reset to Scanned** | A book's detail page | Per-item | That one item |
|
||||||
|
|
||||||
|
The details behind the table:
|
||||||
|
|
||||||
|
- **Force Rescan All Libraries** loops over every library and starts a
|
||||||
|
force scan job for each. Libraries that already have a scan running are
|
||||||
|
left alone — the button reports which libraries started and which were
|
||||||
|
skipped because a scan was already in flight.
|
||||||
|
- **Force Rescan** on a library row is the same force rescan, scoped to
|
||||||
|
that one library. It shares the dashboard button's semantics exactly.
|
||||||
|
- **Scan this folder** is the only folder-sized scan, and the only shallow
|
||||||
|
one an admin triggers by hand: unchanged files are skipped, and only
|
||||||
|
that folder's subtree is walked. Because a partial scan can't vouch for
|
||||||
|
what it didn't look at, it never marks items from the library's *other*
|
||||||
|
folders as missing. Podcast download folders refuse this button — they
|
||||||
|
are managed by the feed system, not the scanner.
|
||||||
|
- **Auto-Scan** arms the two automatic triggers, both shallow: a
|
||||||
|
filesystem watcher that fires as files change, and a periodic poll that
|
||||||
|
runs every **Scan Interval (seconds)** (default 60, allowed 1–3600).
|
||||||
|
The same shallow pass also runs once at startup when auto-scan is on.
|
||||||
|
- **Retry** and **Retry all** on a library's Processing Issues page send
|
||||||
|
failed items back through the scanner one at a time — deliberately not
|
||||||
|
a library-wide scan, so fixing a few bad files never re-reads the whole
|
||||||
|
library.
|
||||||
|
- **Rescan** on a book's page re-extracts that item from its file and
|
||||||
|
recomputes its hashes. **Reset to Scanned** clears your manual metadata
|
||||||
|
edits first, so what lands is purely what the scanner derives from the
|
||||||
|
file.
|
||||||
|
|
||||||
|
## Rules worth knowing
|
||||||
|
|
||||||
|
- **One scan per library at a time.** Starting a second scan for a library
|
||||||
|
that already has one pending or running is refused — the dashboard
|
||||||
|
tells you a scan is active rather than stacking a duplicate. The
|
||||||
|
automatic triggers skip silently instead.
|
||||||
|
- **Scans run in the background.** The dashboard's Scanning panel shows
|
||||||
|
per-library progress while jobs run; **Cancel all scans** stops the ones
|
||||||
|
still queued or running, and you can keep working meanwhile.
|
||||||
|
- **Missing files archive after two consecutive scans.** When a
|
||||||
|
library-wide scan (shallow or force) can't find a file twice in a row,
|
||||||
|
the item is archived — reading history is kept in case the file comes
|
||||||
|
back. The Archived Items page in this section manages the results.
|
||||||
|
- **Metadata comes from the files themselves.** The scanner reads sidecar
|
||||||
|
files (Calibre's `metadata.opf` in particular), embedded tags, and
|
||||||
|
filenames — [Calibre Integration](../user/calibre-integration.md) covers
|
||||||
|
the sidecar story. What a scan extracts is exactly what a force rescan
|
||||||
|
re-extracts.
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<!-- Ground truth: templates/admin_settings.templ (page structure, save
|
||||||
|
buttons, live/restart buckets), internal/database/settings_registry.go
|
||||||
|
(SettingDefaults: keys, types, defaults, min/max, restart flags,
|
||||||
|
groups — the source of truth), internal/handlers/system_settings.go
|
||||||
|
(ApplySetting validates type+bounds then reloads; UpdateScanSettings
|
||||||
|
validates 1-3600), internal/router/frontend.go (settings page handler,
|
||||||
|
dedicatedUI exclusion list). Re-verify against these when settings
|
||||||
|
change — a new key lands in SettingDefaults AND schema.sql AND here. -->
|
||||||
|
|
||||||
|
# System Settings
|
||||||
|
|
||||||
|
The Settings page is every server-wide knob in one place. Each setting
|
||||||
|
below lists its **key** (the name shown on the page and stored in the
|
||||||
|
database), its **default**, its **valid range**, and whether it applies
|
||||||
|
live or needs a restart.
|
||||||
|
|
||||||
|
## How saving works
|
||||||
|
|
||||||
|
The page has three separate save paths, each with its own button:
|
||||||
|
|
||||||
|
- **Save Settings** (top card) — commits the Base URL and Default
|
||||||
|
Timezone together.
|
||||||
|
- **Save Scan Settings** — commits the two scanning controls.
|
||||||
|
- A per-row **Save** for each tunable setting — you change one value and
|
||||||
|
save exactly that one.
|
||||||
|
|
||||||
|
Live tunables apply immediately. The restart bucket saves your value
|
||||||
|
right away but only takes effect the next time the server restarts — the
|
||||||
|
page marks that bucket in amber so it can't be missed. Invalid values are
|
||||||
|
refused with an error; nothing silently clamps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
**base_url** — string, no default. The public URL of your Bookhoard
|
||||||
|
instance (e.g. `https://books.example.com`). This is the address Bookhoard
|
||||||
|
hands out to devices and OPDS clients — device sync, OPDS, and API
|
||||||
|
endpoint references are built from it — so it must be the URL your other
|
||||||
|
machines can actually reach, not `localhost`. The **URL Paths** card
|
||||||
|
underneath shows the three addresses derived from it (`/opds`, `/api`,
|
||||||
|
`/api/sync`) as a sanity check.
|
||||||
|
|
||||||
|
## System Defaults
|
||||||
|
|
||||||
|
**default_timezone** — string, default `UTC`. The timezone used for any
|
||||||
|
user who hasn't picked their own (each user's profile can override it).
|
||||||
|
This governs how the server renders dates and times for that user —
|
||||||
|
history, stats day boundaries, timestamps. The dropdown offers a fixed
|
||||||
|
list of zones.
|
||||||
|
|
||||||
|
## Scanning
|
||||||
|
|
||||||
|
Two of these live in their own card with **Save Scan Settings**; the last
|
||||||
|
two appear among the Live tunables under a "Scanning" heading. All four
|
||||||
|
are documented together here.
|
||||||
|
|
||||||
|
**auto_scan_enabled** — bool, default on. **Auto-Scan** in the UI. The
|
||||||
|
system-wide switch for automatic scanning: when on, a filesystem watcher
|
||||||
|
fires scans as files change, a periodic poll runs on the interval below,
|
||||||
|
and a shallow scan runs at startup. When off, libraries only change when
|
||||||
|
you scan them by hand. See [Scanning](scanning.md) for shallow vs. force.
|
||||||
|
|
||||||
|
**scan_poll_interval_seconds** — int, default `60`, range 1–3600.
|
||||||
|
**Scan Interval (seconds)** in the UI. How often the automatic poll
|
||||||
|
re-checks every library for changes. Lower is more responsive, higher is
|
||||||
|
less background work; every tick is a shallow scan, so unchanged files
|
||||||
|
cost almost nothing.
|
||||||
|
|
||||||
|
**auto_playlist_collections** — bool, default on. Live tunable. When a
|
||||||
|
book's sidecar metadata marks its series as a *reading order*, Bookhoard
|
||||||
|
automatically creates a collection that gathers the series in reading
|
||||||
|
order. Turn it off to stop creating these during future scans (existing
|
||||||
|
collections stay).
|
||||||
|
|
||||||
|
**scan_extractor_version** — int, default `1`, range 1–1000000. Live
|
||||||
|
tunable. The parser version stamped on each book's scan manifest. This is
|
||||||
|
the gradual backfill lever: raise it after a scanner improvement, and
|
||||||
|
books scanned under the older parser get re-extracted a bounded batch per
|
||||||
|
scan until all are caught up — no manual force rescan required. Leave it
|
||||||
|
alone unless you know a parser change shipped; don't set it far ahead of
|
||||||
|
the real version.
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
**session_duration_seconds** — int, default `604800` (7 days), range 300
|
||||||
|
(5 minutes)–31536000 (1 year). How long a login session stays valid.
|
||||||
|
Shorter is safer for shared machines; longer means fewer re-logins on
|
||||||
|
personal devices.
|
||||||
|
|
||||||
|
## Password Quality
|
||||||
|
|
||||||
|
Five settings that together define every new password — set at
|
||||||
|
registration, created by an admin, or changed by a user. Existing
|
||||||
|
passwords are never retroactively invalidated; the rules apply when a
|
||||||
|
password is next set.
|
||||||
|
|
||||||
|
**password_min_length** — int, default `8`, range 1–128. Minimum
|
||||||
|
character count.
|
||||||
|
|
||||||
|
**password_require_upper** — bool, default on. At least one uppercase
|
||||||
|
letter (A-Z).
|
||||||
|
|
||||||
|
**password_require_lower** — bool, default on. At least one lowercase
|
||||||
|
letter (a-z).
|
||||||
|
|
||||||
|
**password_require_number** — bool, default on. At least one digit (0-9).
|
||||||
|
|
||||||
|
**password_require_special** — bool, default on. At least one special
|
||||||
|
character.
|
||||||
|
|
||||||
|
The Users page's create form and the change-password form build their
|
||||||
|
live requirement checklists from these exact values.
|
||||||
|
|
||||||
|
## Public Registration
|
||||||
|
|
||||||
|
**registration_enabled** — bool, default on. Whether visitors can create
|
||||||
|
their own accounts from the sign-up page. Off, the instance is
|
||||||
|
invite-by-admin: accounts are created only from the
|
||||||
|
[Users](users.md) page (admins can always create accounts).
|
||||||
|
|
||||||
|
## OPDS Catalog
|
||||||
|
|
||||||
|
Page-size controls for the OPDS catalog (what apps like KOReader use to
|
||||||
|
browse your library over the network).
|
||||||
|
|
||||||
|
**opds_default_page_size** — int, default `50`, range 1–500. How many
|
||||||
|
entries a catalog page contains when the client doesn't ask for a
|
||||||
|
specific size.
|
||||||
|
|
||||||
|
**opds_max_page_size** — int, default `200`, range 1–1000. The ceiling
|
||||||
|
on what a client may request — a protection against one app asking for
|
||||||
|
enormous pages.
|
||||||
|
|
||||||
|
## Device Rate Limits
|
||||||
|
|
||||||
|
Per-device request ceilings, per minute, by kind of request. These
|
||||||
|
protect the server from a misbehaving or runaway device; a device that
|
||||||
|
hits its limit is throttled until the minute rolls over. Defaults are
|
||||||
|
sized for normal reading — raise them for very large libraries whose
|
||||||
|
initial device sync needs more headroom.
|
||||||
|
|
||||||
|
**device_rate_sync_per_min** — int, default `60`, range 1–10000. Sync
|
||||||
|
requests per minute (annotations, bookmarks, reading-state sync).
|
||||||
|
|
||||||
|
**device_rate_progress_per_min** — int, default `120`, range 1–10000.
|
||||||
|
Progress updates per minute (position saves as you read).
|
||||||
|
|
||||||
|
**device_rate_metadata_per_min** — int, default `30`, range 1–10000.
|
||||||
|
Metadata requests per minute.
|
||||||
|
|
||||||
|
## Annotation Retention
|
||||||
|
|
||||||
|
**annotation_tombstone_ttl_days** — int, default `30`, range 1–3650.
|
||||||
|
When an annotation (highlight, note, bookmark) is deleted, it isn't
|
||||||
|
destroyed instantly — a tombstone records the deletion so every other
|
||||||
|
device learns about it on their next sync. After this many days the
|
||||||
|
tombstone is purged for good. Longer is safer if some of your devices
|
||||||
|
stay offline for weeks; shorter reclaims rows sooner.
|
||||||
|
|
||||||
|
## Conversion Cache
|
||||||
|
|
||||||
|
**conversion_cache_ttl_hours** — int, default `24`, range 1–720. How
|
||||||
|
long converted files (Kobo-optimized KEPUB, for example) are kept cached
|
||||||
|
after conversion. Longer saves repeat conversion work at the cost of
|
||||||
|
disk; the conversion is regenerated when the cache entry expires.
|
||||||
|
|
||||||
|
## Auth Rate Limiting
|
||||||
|
|
||||||
|
*Restart required.*
|
||||||
|
|
||||||
|
**auth_rate_limit_per_min** — int, default `10`, range 1–10000. Global
|
||||||
|
cap on authentication requests per minute — the brute-force damper on
|
||||||
|
the login endpoints, server-wide. Raise it only if legitimate logins are
|
||||||
|
being throttled (many users behind one NAT, for example).
|
||||||
|
|
||||||
|
## Login Lockout
|
||||||
|
|
||||||
|
*Restart required.* Two settings that work as a pair: after
|
||||||
|
**login_max_attempts** consecutive failed attempts, that account waits
|
||||||
|
**login_lockout_minutes** before accepting more tries.
|
||||||
|
|
||||||
|
**login_max_attempts** — int, default `5`, range 1–100.
|
||||||
|
|
||||||
|
**login_lockout_minutes** — int, default `15`, range 1–10080 (up to one
|
||||||
|
week).
|
||||||
|
|
||||||
|
## Sync Queue
|
||||||
|
|
||||||
|
*Restart required.* Device-originated sync changes flow through a
|
||||||
|
background queue instead of being written the instant they arrive.
|
||||||
|
|
||||||
|
**sync_queue_interval_seconds** — int, default `5`, range 1–3600. How
|
||||||
|
often the queue flushes.
|
||||||
|
|
||||||
|
**sync_queue_batch_size** — int, default `50`, range 1–10000. The
|
||||||
|
maximum number of queued items processed per flush.
|
||||||
|
|
||||||
|
## Worker Pool
|
||||||
|
|
||||||
|
*Restart required.* Background jobs — scans, sync-queue flushes, and
|
||||||
|
other async work — run on a fixed pool of workers.
|
||||||
|
|
||||||
|
**worker_pool_size** — int, default `3`, range 1–100. How many jobs run
|
||||||
|
concurrently. More workers chew through queued work faster at the cost
|
||||||
|
of CPU and database load; on a small home server, more is not better.
|
||||||
|
|
||||||
|
**worker_queue_cap** — int, default `100`, range 1–10000. How many jobs
|
||||||
|
may wait in line when every worker is busy. Work offered beyond the cap
|
||||||
|
is refused rather than queued — a large force rescan of many libraries
|
||||||
|
wants headroom here.
|
||||||
|
|
||||||
|
## Podcasts
|
||||||
|
|
||||||
|
How often feeds are checked and how downloads behave server-wide.
|
||||||
|
|
||||||
|
**podcast_poll_interval_seconds** — int, default `3600` (one hour),
|
||||||
|
range 300 (5 minutes)–604800 (one week). How often Bookhoard checks
|
||||||
|
feeds for new episodes. Feeds marked *complete* (podcasts that have
|
||||||
|
ended) back off instead of being checked hourly forever.
|
||||||
|
|
||||||
|
**podcast_download_concurrency** — int, default `4`, range 1–16. Episode
|
||||||
|
downloads in flight server-wide; fair-share scheduling spreads them
|
||||||
|
across feeds so one feed can't hog the slots.
|
||||||
|
|
||||||
|
**podcast_download_speed_mbps** — float, default `0` (unlimited), range
|
||||||
|
0–10000. A shared bandwidth cap, in megabits per second, across all
|
||||||
|
podcast downloads — useful when the server's connection is also serving
|
||||||
|
readers.
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
The version of Bookhoard this server is running. Worth checking before
|
||||||
|
filing a problem — and after an update, to confirm the new build landed.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!-- Ground truth: templates/admin_users.templ (create form, user cards,
|
||||||
|
last-admin guards), templates/profile_form.templ + profile_modal.templ
|
||||||
|
(admin edit fields), internal/router/auth.go (admin user endpoints),
|
||||||
|
internal/router/frontend.go (profile-modal route), user delete cascade
|
||||||
|
(reading data is owned by the account and goes with it). Re-verify
|
||||||
|
against these when the page changes. -->
|
||||||
|
|
||||||
|
# Users
|
||||||
|
|
||||||
|
User Management is where accounts are created, roles are assigned, and
|
||||||
|
accounts are removed. Changes here are immediate — there is no approval
|
||||||
|
flow.
|
||||||
|
|
||||||
|
## Creating an account
|
||||||
|
|
||||||
|
**+ Create User** opens the creation form (it stays hidden until clicked,
|
||||||
|
so the page doesn't shout account creation at you — and so admins can
|
||||||
|
still create accounts even when public self-registration is switched off
|
||||||
|
in [Settings](settings.md) → Public Registration).
|
||||||
|
|
||||||
|
Fields: **Username** (3–50 characters), **Email**, **Password** plus a
|
||||||
|
confirm, optional **First name** / **Last name**, and a **Role** of User
|
||||||
|
or Admin. The **Password Requirements** list is generated from the
|
||||||
|
password rules currently configured in Settings — the same rules users
|
||||||
|
face when changing their own passwords — and the **Create User** button
|
||||||
|
stays disabled until everything validates.
|
||||||
|
|
||||||
|
## The user cards
|
||||||
|
|
||||||
|
Every account renders as a card: username (with a **You** badge on your
|
||||||
|
own), email, the creation date shown in your timezone, and the controls:
|
||||||
|
|
||||||
|
### Role
|
||||||
|
|
||||||
|
A dropdown — **User** or **Admin** — that applies the moment you change
|
||||||
|
it. One guard exists: the **last admin cannot be demoted** (the dropdown
|
||||||
|
disables itself with that explanation while only one admin exists).
|
||||||
|
|
||||||
|
Admins see and use the entire Administration section — libraries,
|
||||||
|
scans, settings, this page.
|
||||||
|
|
||||||
|
### Edit
|
||||||
|
|
||||||
|
Opens an **Edit User Profile** modal with the same fields the user
|
||||||
|
controls on their own profile — username, email, first/last name,
|
||||||
|
**Theme**, **Default Playback Speed**, **Resume Rewind** — plus the Role
|
||||||
|
dropdown, and a **Change Password** section. The admin version doesn't
|
||||||
|
ask for the user's current password (only the user's own profile does).
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
|
||||||
|
Removes the account permanently — and with it everything the account
|
||||||
|
owns: devices, sessions, and reading data (progress, annotations,
|
||||||
|
collections). The confirm dialog makes the irreversibility explicit.
|
||||||
|
Same last-admin guard as the role dropdown: the final admin cannot be
|
||||||
|
deleted.
|
||||||
|
|
||||||
|
## Roles, briefly
|
||||||
|
|
||||||
|
- **User** — browses, reads, listens, syncs devices; sees only the
|
||||||
|
libraries visible to them (visibility is set per-library on the
|
||||||
|
[Libraries](libraries.md) page).
|
||||||
|
- **Admin** — everything a user has, plus the Administration section:
|
||||||
|
libraries and scanning, users, system settings, hash conflicts,
|
||||||
|
podcasts maintenance.
|
||||||
@@ -720,3 +720,50 @@ the briefing acceptance now starts from the page's FIRST card (feed
|
|||||||
position 5 — the born-reversed default's book order) and chains into
|
position 5 — the born-reversed default's book order) and chains into
|
||||||
feed position 1. The acceptance meaning is unchanged: play from what
|
feed position 1. The acceptance meaning is unchanged: play from what
|
||||||
you see, chained back-to-back within the live briefing set.
|
you see, chained back-to-back within the live briefing set.
|
||||||
|
|
||||||
|
## 2026-11-01 — audio resume rewind setting
|
||||||
|
|
||||||
|
- `GET /api/media-items/:id/chapters` gained one ADDITIVE response key:
|
||||||
|
`resume_rewind_seconds` (int, the requesting user's setting; 0 for
|
||||||
|
anonymous). Existing keys unchanged. The web player and the Android
|
||||||
|
app both gate the value through the 0/5/10 allow-list, so servers
|
||||||
|
without the key (absent → 0) degrade to exact resume.
|
||||||
|
- `GET /api/auth/profile` now always carries
|
||||||
|
`audio_resume_rewind_seconds` (int, never omitted — 0 is a real
|
||||||
|
value, exact resume). `PUT /api/auth/profile` accepts the key as the
|
||||||
|
keep_screen_awake-style STRING ("0"/"5"/"10") so the profile form's
|
||||||
|
select can send an explicit Off; JSON clients that omit it leave the
|
||||||
|
column untouched, and anything outside {0,5,10} is 400.
|
||||||
|
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
|
||||||
|
chapters payload, non-allow-listed rejections change nothing,
|
||||||
|
explicit "0" resets everywhere, omitted key leaves it untouched.
|
||||||
|
|
||||||
|
## 2026-11-02 — pairing codes replace the registration broadcast
|
||||||
|
|
||||||
|
- `POST /api/devices/register` gained one ADDITIVE response key:
|
||||||
|
`pairing_code` (string, 6 digits) — the claim credential the
|
||||||
|
registering device displays. Existing keys (`registration_id`,
|
||||||
|
`auth_url`, `qr_code`, `expires_in`, `poll_interval`,
|
||||||
|
`setup_instructions`) unchanged, so the Android app's DTO is
|
||||||
|
untouched. Request parse set unchanged.
|
||||||
|
- The four pinned KOReader sync surfaces (progress push/pull, library,
|
||||||
|
bookmarks push) and the Kobo surfaces are byte-identical — pairing
|
||||||
|
codes only touch the registration/approval path, never sync.
|
||||||
|
- `GET /api/devices/pending` keeps its response SHAPE but is
|
||||||
|
deprecated-by-emptiness: unclaimed registrations return to no
|
||||||
|
account (the broadcast was the first-approve-wins hole). The keys
|
||||||
|
`registrations`, `pending_registrations`, `total` still emit.
|
||||||
|
- New unpinned surfaces (no prior shape to freeze):
|
||||||
|
`POST /api/devices/claim` (pairing-code → bind to caller's account;
|
||||||
|
HTMX callers get `HX-Refresh` instead of JSON),
|
||||||
|
`POST /api/devices/claim/lookup` (confirm-card data, no binding),
|
||||||
|
`POST /api/devices/register/cancel` (the registering device ends its
|
||||||
|
own attempt; registration UUID bearer).
|
||||||
|
- `POST /api/devices/reject/:registration_id` REMOVED — the broadcast
|
||||||
|
list was its only consumer; unclaimed registrations are invisible by
|
||||||
|
design, so there is nothing left for anyone to reject. Removal
|
||||||
|
recorded here per rule 4 (user-approved plan; the route was the
|
||||||
|
theft vector's cleanup half).
|
||||||
|
- `GET /api/devices/approve/:registration_id` (Android self-approval)
|
||||||
|
keeps its response shape; the registration UUID is now only ever
|
||||||
|
known to the device that created it, since no list publishes it.
|
||||||
@@ -4,7 +4,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
|||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
- **[Development Guide](Development.md)** - Development workflow and architecture
|
- **[Development Guide](development.md)** - Development workflow and architecture
|
||||||
- Architecture overview
|
- Architecture overview
|
||||||
- Directory structure
|
- Directory structure
|
||||||
- Local development setup
|
- Local development setup
|
||||||
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
|||||||
|
|
||||||
## 📋 Project Guidelines
|
## 📋 Project Guidelines
|
||||||
|
|
||||||
- **[AGENTS.md](../../AGENTS.md)** - Agent/development rules
|
- **[AGENTS.md](../contributing/contributing.md)** - Development rules and standards live in the Contributing portal
|
||||||
- Critical prohibitions
|
- Critical prohibitions
|
||||||
- Mandatory requirements
|
- Mandatory requirements
|
||||||
- Error recovery protocol
|
- Error recovery protocol
|
||||||
@@ -22,7 +22,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
|||||||
|
|
||||||
## 🤝 How to Contribute
|
## 🤝 How to Contribute
|
||||||
|
|
||||||
We welcome contributions! Please see our [Development Guide](Development.md) for information on:
|
We welcome contributions! Please see our [Development Guide](development.md) for information on:
|
||||||
|
|
||||||
- Setting up your development environment
|
- Setting up your development environment
|
||||||
- Understanding the codebase
|
- Understanding the codebase
|
||||||
|
|||||||
@@ -287,6 +287,15 @@ Test-created data rules (harness cleanup depends on them): users use
|
|||||||
`@tests.bookhoard.internal` / `@example.com` emails, libraries and
|
`@tests.bookhoard.internal` / `@example.com` emails, libraries and
|
||||||
collections carry "test"-prefixed names, and cleanups delete test
|
collections carry "test"-prefixed names, and cleanups delete test
|
||||||
libraries (cascades handle the rest) rather than table-wide deletes.
|
libraries (cascades handle the rest) rather than table-wide deletes.
|
||||||
|
Devices attach to the per-run regular user when it doesn't matter, or
|
||||||
|
self-delete via `TestDeviceSetup.CreateDevice` / `createContractDevice`
|
||||||
|
/ `cleanupDeviceByIdentifier` when they attach to the shared admin —
|
||||||
|
names lacking "test" (device-type names like "koreader", the
|
||||||
|
"Reinstall Device" label) escaped the name sweeps for weeks and piled
|
||||||
|
476 rows onto the live dev account; every suite now cleans its own
|
||||||
|
rows at test end, with the harness sweep's identifier/token patterns
|
||||||
|
(`contract-%`, `opds-folders-%`, `repro-reinstall-%`,
|
||||||
|
`dev_contract_*`) as the drift net.
|
||||||
|
|
||||||
### Media Fixtures (audio/books)
|
### Media Fixtures (audio/books)
|
||||||
|
|
||||||
@@ -510,9 +519,9 @@ DEBUG=true
|
|||||||
|
|
||||||
## 📚 Additional Resources
|
## 📚 Additional Resources
|
||||||
|
|
||||||
- [AGENTS.md](../../AGENTS.md) - Agent/development rules
|
- [Development rules and standards](contributing.md) - Contributing portal
|
||||||
- [API Reference](../API_REFERENCE.md) - Complete API documentation
|
- [API Reference](../developer/api/api-reference.md) - Split endpoint docs, indexed from its overview
|
||||||
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
|
- [Troubleshooting](../operations/troubleshooting.md) - Deployment issues
|
||||||
- [Go Documentation](https://go.dev/doc/)
|
- [Go Documentation](https://go.dev/doc/)
|
||||||
- [Echo Framework](https://echo.labstack.com/docs)
|
- [Echo Framework](https://echo.labstack.com/docs)
|
||||||
- [pgx Documentation](https://pgx.github.io/pgx/)
|
- [pgx Documentation](https://pgx.github.io/pgx/)
|
||||||
|
|||||||
@@ -1,92 +1,103 @@
|
|||||||
# Alpine.js Patterns in Bookhoard
|
<!-- Ground truth: web/src/alpine.ts (the @alpinejs/csp build — this is
|
||||||
## Book Picker Modal Pattern (SSR + HTMX + Alpine.store)
|
load-bearing for every rule below), component registrations across
|
||||||
For modals that need state persistence across HTMX swaps:
|
web/src (Alpine.data in admin.ts, api.ts, audio.ts, book-detail.ts,
|
||||||
### TypeScript
|
bookPicker.ts), the bookPicker store (web/src/bookPicker.ts), the
|
||||||
|
dynamic-attribute patterns in templates/header.templ
|
||||||
|
(x-data="sidebarShell('…')" whole-expression values) and
|
||||||
|
web/src/custom-section-builder.ts (data-attributes instead of inline
|
||||||
|
handlers — the CSP build refuses them). Re-verify when the frontend
|
||||||
|
stack changes. -->
|
||||||
|
|
||||||
|
# Alpine.js patterns in Bookhoard
|
||||||
|
|
||||||
|
Bookhoard's Alpine runs the **`@alpinejs/csp` build** — the CSP build, so
|
||||||
|
the app's `script-src` can stay a hash allow-list with no
|
||||||
|
`'unsafe-eval'`. That single fact dictates most of this page: inline
|
||||||
|
JavaScript in HTML is not evaluated, so everything expressive lives in
|
||||||
|
registered TypeScript components.
|
||||||
|
|
||||||
|
## Rule one: components are registered, not inlined
|
||||||
|
|
||||||
|
Every `x-data` references a component registered with `Alpine.data` in
|
||||||
|
`web/src` — the attribute value is the component name (optionally with
|
||||||
|
arguments), never an object literal:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// web/src/admin.ts
|
||||||
|
Alpine.data("admin", () => ({ /* state + methods */ }));
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- templates/admin.templ -->
|
||||||
|
<body x-data="admin" class="theme-tokyo-night">
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments work, but the **whole attribute value must be the expression**
|
||||||
|
— templ does not interpolate `{ expr }` inside quoted attribute values,
|
||||||
|
so `x-data="sidebarShell('{ col.ID }')"` would ship a literal `{ col.ID }`
|
||||||
|
to the browser:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div x-data={ "sidebarShell('" + string(mode) + "')" }>
|
||||||
|
```
|
||||||
|
|
||||||
|
Free-text arguments that could contain quotes go through
|
||||||
|
`templates.JSONArg`, never string concatenation.
|
||||||
|
|
||||||
|
## Stores: state that survives htmx swaps
|
||||||
|
|
||||||
|
Alpine's reactivity is DOM-local; when htmx swaps a node, its `x-data`
|
||||||
|
state dies with it. State that must survive swaps lives in a store —
|
||||||
|
`web/src/bookPicker.ts` is the canonical example:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// web/src/bookPicker.ts
|
|
||||||
Alpine.store("bookPicker", {
|
Alpine.store("bookPicker", {
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
selectedBooks: new Set<string>(),
|
selectedBooks: new Set<string>(),
|
||||||
|
open() { /* … */ },
|
||||||
open() {
|
toggleBook(id: string) { /* … */ },
|
||||||
this.isOpen = true;
|
|
||||||
this.selectedBooks.clear();
|
|
||||||
this.loadBooks();
|
|
||||||
},
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.isOpen = false;
|
|
||||||
this.selectedBooks.clear();
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleBook(id: string) {
|
|
||||||
if (this.selectedBooks.has(id)) {
|
|
||||||
this.selectedBooks.delete(id);
|
|
||||||
} else {
|
|
||||||
this.selectedBooks.add(id);
|
|
||||||
}
|
|
||||||
// Trigger reactivity
|
|
||||||
this.selectedBooks = new Set(this.selectedBooks);
|
|
||||||
},
|
|
||||||
|
|
||||||
isSelected(id: string): boolean {
|
|
||||||
return this.selectedBooks.has(id);
|
|
||||||
},
|
|
||||||
|
|
||||||
get selectedCount(): number {
|
|
||||||
return this.selectedBooks.size;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
Template
|
```
|
||||||
<!-- Open button -->
|
|
||||||
|
```html
|
||||||
<button @click="$store.bookPicker.open()">Add Books</button>
|
<button @click="$store.bookPicker.open()">Add Books</button>
|
||||||
<!-- Modal -->
|
<span x-text="$store.bookPicker.selectedBooks.size"></span>
|
||||||
<div x-show="$store.bookPicker.isOpen"
|
```
|
||||||
x-transition
|
|
||||||
@click.self="$store.bookPicker.close()"
|
The server returns HTML (checked checkboxes included); the store tracks
|
||||||
style="display: none;">
|
selection across swaps; nobody rebuilds markup from JSON.
|
||||||
|
|
||||||
<!-- Search with HTMX -->
|
## Local state: plain `x-data` on the smallest element
|
||||||
<input @input.debounce.300ms="searchBooks($el.value)"
|
|
||||||
hx-get="/api/media-items/filtered"
|
Ephemeral UI (a dropdown, a panel toggle) needs no store and no
|
||||||
hx-target="#book-picker-grid"
|
round-trip:
|
||||||
hx-include="#filter-form" />
|
|
||||||
|
```html
|
||||||
<!-- Book grid with checkboxes -->
|
|
||||||
<div id="book-picker-grid" x-init="loadBooks()">
|
|
||||||
<!-- Server returns HTML with checkboxes -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected count -->
|
|
||||||
<span x-text="$store.bookPicker.selectedCount"></span>
|
|
||||||
|
|
||||||
<!-- Submit -->
|
|
||||||
<button @click="$store.bookPicker.submit()">Add Selected</button>
|
|
||||||
</div>
|
|
||||||
Key Principles
|
|
||||||
- ✅ Alpine.store persists state across HTMX DOM swaps
|
|
||||||
- ✅ Server returns HTML with checkbox :checked attributes
|
|
||||||
- ✅ No client-side rendering or manual DOM manipulation
|
|
||||||
- ✅ HTMX handles dynamic content updates
|
|
||||||
- ✅ Alpine manages UI state only
|
|
||||||
Dropdown Pattern (Local x-data)
|
|
||||||
<div x-data="{ dropdownOpen: false }">
|
<div x-data="{ dropdownOpen: false }">
|
||||||
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
|
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
|
||||||
<div x-show="dropdownOpen"
|
<div x-show="dropdownOpen" @click.outside="dropdownOpen = false">…</div>
|
||||||
@click.outside="dropdownOpen = false"
|
|
||||||
x-transition
|
|
||||||
style="display: none;">
|
|
||||||
<!-- Dropdown content -->
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
Best Practices
|
```
|
||||||
- ✅ State lives in template (x-data)
|
|
||||||
- ✅ UI updates automatically (x-show)
|
## The banned list — all enforced by the CSP build
|
||||||
- ✅ No manual DOM manipulation in TypeScript
|
|
||||||
- ✅ Pure business logic only in TypeScript functions
|
- **No inline handlers**, in any form: `onclick=`, `onerror=`,
|
||||||
- ✅ Use Alpine.store for global state
|
templ-brace variants — hash-mode CSP refuses them unconditionally.
|
||||||
- ✅ Use local x-data for component state
|
Rows generated in TypeScript carry `data-*` attributes and bind
|
||||||
- ✅ Use x-init for setup only (no data fetch for SSR pages)
|
listeners after swap (see `custom-section-builder.ts`).
|
||||||
- ❌ Never use classList in TypeScript
|
- **No `hx-on*` attributes and no `js:`-prefixed `hx-vals`** — both
|
||||||
- ❌ Never use getElementById for show/hide
|
evaluate strings, which the CSP build cannot do. Plain JSON `hx-vals`
|
||||||
- ❌ Never fetch data in x-init (for SSR pages)
|
only (`templates/utils.go` has the helper).
|
||||||
|
- **No inline `<script>` outside `theme_flash_guard.templ`** (the one
|
||||||
|
sanctioned pre-paint script); everything else bundles from `web/src`.
|
||||||
|
|
||||||
|
## Division of labor
|
||||||
|
|
||||||
|
The full ownership rule lives in
|
||||||
|
[Writing UI documentation without screenshots](writing-ui-docs.md) and
|
||||||
|
the repo's AGENTS.md: **templ decides what things look like; TypeScript
|
||||||
|
decides what shows.** Content derived from server data is rendered by
|
||||||
|
templ (fetch a partial with `htmx.ajax` when it changes); TypeScript
|
||||||
|
handles pure client-local state — open/closed panels, validation,
|
||||||
|
toasts. `classList`/`getElementById` show-hide from TypeScript is a
|
||||||
|
defect, not a shortcut.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Android App — Design & Roadmap
|
# Android App — Design & Roadmap
|
||||||
|
|
||||||
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as the audiobookshelf app to an audiobookshelf server, or the Kindle app to Kindle cloud — the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as any dedicated reader app to its server: the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
||||||
|
|
||||||
**Status**: Planning / pre-development
|
**Status**: Planning / pre-development
|
||||||
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
|
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
|
||||||
@@ -32,7 +32,7 @@ This document describes the planned native Android client for Bookhoard: a thin,
|
|||||||
|
|
||||||
### Why native Android
|
### Why native Android
|
||||||
|
|
||||||
- The quality bar is the Kindle app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
- The quality bar is a first-class native reader app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
||||||
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
|
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
|
||||||
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
|
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
|
||||||
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
|
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
|
||||||
|
|||||||
@@ -144,9 +144,11 @@ See [Device Registration & Sync](devices/get_devices.md)
|
|||||||
- GET /api/devices/:id - Get device
|
- GET /api/devices/:id - Get device
|
||||||
- PUT /api/devices/:id - Update device
|
- PUT /api/devices/:id - Update device
|
||||||
- DELETE /api/devices/:id - Delete device
|
- DELETE /api/devices/:id - Delete device
|
||||||
- GET /api/devices/pending - List pending registrations (admin)
|
- GET /api/devices/pending - List pending registrations (always empty — pairing codes replaced the broadcast list)
|
||||||
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
- GET /api/devices/approve/:registration_id - Approve registration by UUID (Android self-approval path)
|
||||||
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
- POST /api/devices/claim - Claim a registration by its pairing code ([Pairing codes](devices/claim_device.md))
|
||||||
|
- POST /api/devices/claim/lookup - Resolve a pairing code without binding ([Lookup](devices/lookup_pairing_code.md))
|
||||||
|
- POST /api/devices/register/cancel - The registering device cancels its own pairing attempt ([Cancel](devices/cancel_device_registration.md))
|
||||||
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
||||||
- GET /api/devices/:id/shelves - Get shelf contents
|
- GET /api/devices/:id/shelves - Get shelf contents
|
||||||
- DELETE /api/devices/:id/shelves - Remove from shelf
|
- DELETE /api/devices/:id/shelves - Remove from shelf
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
method: POST
|
||||||
|
path: /api/devices/register/cancel
|
||||||
|
auth: none
|
||||||
|
area: devices
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/devices.go DeviceHandler.CancelRegistration -->
|
||||||
|
|
||||||
|
# Cancel Device Registration
|
||||||
|
|
||||||
|
Let the REGISTERING device end its own pairing attempt — the plugin calls this
|
||||||
|
when the user dismisses its waiting dialog. The `registration_id` is the
|
||||||
|
bearer credential (same trust as the status poll): a device can only ever
|
||||||
|
cancel its own registration, and already-claimed registrations are left alone
|
||||||
|
so their token delivery to the device is never disrupted. Idempotent: the
|
||||||
|
response is the same whether or not the registration still exists.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/devices/register/cancel`
|
||||||
|
**Auth**: Not required (registration UUID bearer, like the status poll)
|
||||||
|
**Content-Type**: `application/json`
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| --------------- | ------ | -------- | ------------------------------ |
|
||||||
|
| registration_id | string | Yes | UUID from the register response |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```json example-request
|
||||||
|
{
|
||||||
|
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK)
|
||||||
|
|
||||||
|
```json example-response
|
||||||
|
{
|
||||||
|
"message": "registration cancelled"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
method: POST
|
||||||
|
path: /api/devices/claim
|
||||||
|
auth: jwt
|
||||||
|
area: devices
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/devices.go DeviceHandler.ClaimDevice -->
|
||||||
|
|
||||||
|
# Claim Device (Pairing Code)
|
||||||
|
|
||||||
|
Bind a pending device registration to the CALLER's account by entering the
|
||||||
|
6-digit pairing code shown on the registering device's screen. This is the
|
||||||
|
web-side replacement for the retired broadcast-list approve: unclaimed
|
||||||
|
registrations appear in no account's pending list, and only whoever reads the
|
||||||
|
code — the person holding the device — can claim it.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/devices/claim`
|
||||||
|
**Auth**: JWT
|
||||||
|
**Content-Type**: `application/json`
|
||||||
|
|
||||||
|
The code is normalized before matching: spaces and dashes typed around the
|
||||||
|
digits ("583 912") are stripped. Lookups and claims share a per-account rate
|
||||||
|
limit (12 attempts/minute).
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------ | ------ | -------- | -------------------------------------------- |
|
||||||
|
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```json example-request
|
||||||
|
{
|
||||||
|
"pairing_code": "583912"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK)
|
||||||
|
|
||||||
|
```json example-response
|
||||||
|
{
|
||||||
|
"message": "device paired successfully",
|
||||||
|
"device_name": "My Kindle Paperwhite",
|
||||||
|
"device_type": "koreader",
|
||||||
|
"approved": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTMX callers (the Devices page confirm button) receive `HX-Refresh` instead of
|
||||||
|
a JSON body. On success the registering device's status poll returns the fresh
|
||||||
|
`auth_token` and sync endpoints, exactly as in the old approve flow.
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ------------------------------------------------------ |
|
||||||
|
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
|
||||||
|
| 409 | Pairing code already used, or the device identifier is registered to another user |
|
||||||
|
| 410 | Pairing code expired |
|
||||||
|
| 429 | Too many pairing attempts |
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `POST /api/devices/claim/lookup` — the confirm card's data, without binding
|
||||||
|
- `POST /api/devices/register` — starts the registration and mints the code
|
||||||
@@ -4,21 +4,19 @@ path: /api/devices/pending
|
|||||||
auth: jwt
|
auth: jwt
|
||||||
area: devices
|
area: devices
|
||||||
---
|
---
|
||||||
<!-- source: internal/handlers/devices.go:703 DeviceHandler.ListPendingRegistrations -->
|
<!-- source: internal/handlers/devices.go DeviceHandler.ListPendingRegistrations -->
|
||||||
|
|
||||||
# List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
|
# List Pending Device Registrations
|
||||||
/home/nymusicman/Code/bookhoard/docs/developer/api/devices/reject_device_registration.md
|
|
||||||
|
|
||||||
List all pending device registration requests.
|
Always empty by design. Unclaimed device registrations are invisible to every
|
||||||
|
account since pairing codes took over the approval flow: first-approve-wins on
|
||||||
|
a broadcast list let any account claim (or reject) a device it could not even
|
||||||
|
name. Instead, the registering device shows a 6-digit pairing code, and the
|
||||||
|
person holding the device types it into their own account's Devices page —
|
||||||
|
`POST /api/devices/claim`.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/devices/pending`
|
**Endpoint**: `GET /api/devices/pending`
|
||||||
**Auth**: Required (Admin only)
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Type | Required | Description |
|
|
||||||
| ------------- | ------ | -------- | ----------------------------------- |
|
|
||||||
| Authorization | string | Yes | Bearer token (must have admin role) |
|
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
@@ -31,23 +29,16 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
|
|
||||||
```json example-response
|
```json example-response
|
||||||
{
|
{
|
||||||
"registrations": [
|
"registrations": [],
|
||||||
{
|
"pending_registrations": [],
|
||||||
"id": "uuid",
|
"total": 0
|
||||||
"device_name": "My Kobo",
|
|
||||||
"device_type": "kobo",
|
|
||||||
"user_email": "user@example.com",
|
|
||||||
"created_at": "2026-02-08T10:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 3,
|
|
||||||
"pending_registrations": null
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
Both `registrations` and `pending_registrations` key spellings are emitted for
|
||||||
|
API compatibility.
|
||||||
|
|
||||||
| Code | Description |
|
## Related
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 401 | Invalid or expired token |
|
- [Register Device](register_device.md) — mints the pairing code
|
||||||
| 403 | User does not have admin privileges |
|
- [Claim Device](claim_device.md) — the code-driven replacement for list-and-approve
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
method: POST
|
||||||
|
path: /api/devices/claim/lookup
|
||||||
|
auth: jwt
|
||||||
|
area: devices
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/devices.go DeviceHandler.LookupPairingCode -->
|
||||||
|
|
||||||
|
# Look Up Pairing Code
|
||||||
|
|
||||||
|
Resolve a pairing code to its pending registration WITHOUT binding it — the
|
||||||
|
data the Devices page confirm card shows ("Pair 'My Kindle Paperwhite'?").
|
||||||
|
Shares the per-account attempt budget with `POST /api/devices/claim`.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/devices/claim/lookup`
|
||||||
|
**Auth**: JWT
|
||||||
|
**Content-Type**: `application/json`
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------ | ------ | -------- | -------------------------------------------- |
|
||||||
|
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```json example-request
|
||||||
|
{
|
||||||
|
"pairing_code": "583912"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK)
|
||||||
|
|
||||||
|
```json example-response
|
||||||
|
{
|
||||||
|
"device_name": "My Kindle Paperwhite",
|
||||||
|
"device_type": "koreader",
|
||||||
|
"device_identifier": "koreader-hostname",
|
||||||
|
"expires_in": 245
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ------------------------------------------------------ |
|
||||||
|
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
|
||||||
|
| 409 | Pairing code already used |
|
||||||
|
| 410 | Pairing code expired |
|
||||||
|
| 429 | Too many pairing attempts |
|
||||||
@@ -40,6 +40,7 @@ Register a new device for sync.
|
|||||||
"registration_id": "registration-uuid",
|
"registration_id": "registration-uuid",
|
||||||
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||||
|
"pairing_code": "583912",
|
||||||
"expires_in": 300,
|
"expires_in": 300,
|
||||||
"poll_interval": 3,
|
"poll_interval": 3,
|
||||||
"setup_instructions": {
|
"setup_instructions": {
|
||||||
@@ -48,7 +49,14 @@ Register a new device for sync.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
`pairing_code` is the claim credential: the registering device displays it, and the
|
||||||
|
only person who can pair the device is whoever reads it (they type it into the
|
||||||
|
Devices page of THEIR account — `POST /api/devices/claim`). Unclaimed registrations
|
||||||
|
appear in no account's pending list, so there is no first-approver race.
|
||||||
|
Registrations expire after 5 minutes; re-registering the same `device_identifier`
|
||||||
|
retires the earlier attempt. 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
|
## Error Responses
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
method: POST
|
|
||||||
path: /api/devices/reject/:registration_id
|
|
||||||
auth: jwt
|
|
||||||
area: devices
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/devices.go:664 DeviceHandler.RejectDevice -->
|
|
||||||
|
|
||||||
# Reject Device Registration
|
|
||||||
|
|
||||||
Reject a pending device registration request.
|
|
||||||
|
|
||||||
**Endpoint**: `POST /api/devices/reject/:registration_id`
|
|
||||||
**Auth**: Required (Admin only)
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------------- | ------------- | -------- | ------------------------- |
|
|
||||||
| registration_id | string (UUID) | Yes | Registration request UUID |
|
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Type | Required | Description |
|
|
||||||
| ------------- | ------ | -------- | ----------------------------------- |
|
|
||||||
| Authorization | string | Yes | Bearer token (must have admin role) |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/devices/reject/550e8400-e29b-41d4-a716-446655440000
|
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (200 OK)
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"message": "device registration rejected"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 401 | Invalid or expired token |
|
|
||||||
| 403 | User does not have admin privileges |
|
|
||||||
| 404 | Registration not found |
|
|
||||||
| 400 | Registration already processed |
|
|
||||||
@@ -36,6 +36,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
"first_name": "John",
|
"first_name": "John",
|
||||||
"last_name": "Doe",
|
"last_name": "Doe",
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
"default_audio_speed": 1.5,
|
||||||
|
"audio_resume_rewind_seconds": 5,
|
||||||
"keep_screen_awake": true
|
"keep_screen_awake": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# WebSocket Protocol
|
# WebSocket Protocol
|
||||||
|
|
||||||
Real-time sync events broadcast to connected clients.
|
Real-time sync events, delivered only to the acting user's connected
|
||||||
|
devices.
|
||||||
|
|
||||||
## Connect to WebSocket
|
## Connect to WebSocket
|
||||||
|
|
||||||
@@ -24,15 +25,16 @@ All messages are JSON objects with a `type` field.
|
|||||||
|
|
||||||
### Client → Server Messages
|
### Client → Server Messages
|
||||||
|
|
||||||
#### Ping (Heartbeat)
|
None are interpreted. The socket is a one-directional push feed — the
|
||||||
|
server logs (but ignores) anything a client sends. All reads and writes
|
||||||
|
go through the REST API.
|
||||||
|
|
||||||
```json
|
#### Keepalive
|
||||||
{
|
|
||||||
"type": "ping"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep connection alive. Server responds with `pong`.
|
The server sends a WebSocket **ping frame every 30 seconds**; a
|
||||||
|
connection that misses pongs for 90 seconds is removed. Standard
|
||||||
|
WebSocket clients (browsers included) answer pongs automatically —
|
||||||
|
there is nothing to implement.
|
||||||
|
|
||||||
### Server → Client Messages
|
### Server → Client Messages
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +1,90 @@
|
|||||||
# Developer Documentation
|
# Developer Documentation
|
||||||
|
|
||||||
Welcome to the Bookhoard developer documentation. This section contains API references, protocol specifications, and technical documentation for integrating with Bookhoard.
|
The developer tier of Bookhoard's docs: **how it works** — wire formats,
|
||||||
|
data access, pipelines, and the rules the codebase enforces on you.
|
||||||
|
(User docs tell you what things do; admin docs what the controls do;
|
||||||
|
this section is the machinery itself.) Everything here is written
|
||||||
|
against the source and kept honest by gates — if a doc and the code
|
||||||
|
disagree, one of them fails `make test`.
|
||||||
|
|
||||||
## 📚 API Documentation
|
## Start here: architecture, setup, testing
|
||||||
|
|
||||||
### Complete API Reference
|
**[Development Guide](../contributing/development.md)** — the working
|
||||||
|
developer's page: directory structure, backend components, database
|
||||||
|
schema, tech stack, local setup, code generation (sqlc / templ /
|
||||||
|
vite), build targets, and the full testing doctrine with its rules.
|
||||||
|
|
||||||
- **[API Reference](api/api-reference.md)** - Complete REST API reference
|
## API Reference
|
||||||
- Authentication
|
|
||||||
- User management
|
|
||||||
- Libraries
|
|
||||||
- Media items
|
|
||||||
- Reading progress
|
|
||||||
- Notes & highlights
|
|
||||||
- Analytics
|
|
||||||
- Book matching
|
|
||||||
- OPDS
|
|
||||||
- Sync protocols (KOReader, Kobo)
|
|
||||||
- WebSocket
|
|
||||||
|
|
||||||
### Split Endpoint Documentation
|
Two hundred endpoint documents, one per route, generated-adjacent and
|
||||||
|
gate-checked against the router (a route without a doc fails the build,
|
||||||
|
and so does a doc whose request examples drift from the Bruno
|
||||||
|
collection):
|
||||||
|
|
||||||
Browse individual endpoint documentation:
|
- **[API Reference overview](api/api-reference.md)** — every area, auth
|
||||||
|
model, and conventions
|
||||||
|
- Entry points: [Authentication](api/authentication/login.md),
|
||||||
|
[Users](api/users/get_profile.md),
|
||||||
|
[Libraries](api/libraries/list_libraries.md),
|
||||||
|
[Media Items](api/media-items/get_media_item.md),
|
||||||
|
[Progress](api/progress/get_media_progress.md),
|
||||||
|
[Notes](api/notes/get_notes.md) ·
|
||||||
|
[Highlights](api/highlights/get_highlights.md) ·
|
||||||
|
[Ratings](api/ratings/get_ratings.md),
|
||||||
|
[Devices](api/devices/get_devices.md),
|
||||||
|
[Collections](api/collections/list_collections.md),
|
||||||
|
[Analytics](api/analytics/get_analytics.md),
|
||||||
|
[Book Matching](api/book-matching/search_books.md),
|
||||||
|
[OPDS](api/opds/feeds.md),
|
||||||
|
[Scanner](api/scanner/overview.md),
|
||||||
|
[Admin](api/admin/list_users.md)
|
||||||
|
- The machine-readable mirror lives at `docs/developer/openapi.yaml`
|
||||||
|
(regenerated by the parity test, never hand-edited).
|
||||||
|
|
||||||
- **[API Documentation](api/api-reference.md)** - Split endpoint docs
|
## Protocols
|
||||||
- [Authentication](api/authentication/login.md) - Register, login, tokens
|
|
||||||
- [Users](api/users/get_profile.md) - Profile management
|
|
||||||
- [Libraries](api/libraries/list_libraries.md) - Library operations
|
|
||||||
- [Media Items](api/media-items/get_media_item.md) - Book/ebook management
|
|
||||||
- [Progress](api/progress/get_media_progress.md) - Reading progress tracking
|
|
||||||
- [Notes](api/notes/get_notes.md) - User notes
|
|
||||||
- [Highlights](api/highlights/get_highlights.md) - Book highlights
|
|
||||||
- [Ratings](api/ratings/get_ratings.md) - Book ratings
|
|
||||||
- [Devices](api/devices/get_devices.md) - Device management
|
|
||||||
- [Analytics](api/analytics/get_analytics.md) - Usage statistics
|
|
||||||
- [Book Matching](api/book-matching/search_books.md) - Search and link books
|
|
||||||
- [Collections](api/collections/list_collections.md) - Collection management
|
|
||||||
- [OPDS](api/opds/feeds.md) - Open Publication Distribution
|
|
||||||
- [Sync Protocols](api/sync/koreader-protocol.md) - KOReader and Kobo sync
|
|
||||||
- [WebSocket](api/websocket/protocol.md) - Real-time events
|
|
||||||
|
|
||||||
### Collections API
|
- **[KOReader Sync Protocol](api/sync/koreader-protocol.md)** — progress,
|
||||||
|
metadata, conflicts; the plugin's server side
|
||||||
|
- **[Kobo Sync Protocol](api/sync/kobo-protocol.md)** — native Kobo
|
||||||
|
sync (server-complete; device rollout pending — use
|
||||||
|
[KOReader](api/sync/koreader-protocol.md) on Kobo hardware today)
|
||||||
|
- **[WebSocket wire format](websocket-api.md)** — real-time events:
|
||||||
|
message catalog, keepalive, fan-out and backpressure contracts
|
||||||
|
- **[WebSocket endpoint](api/websocket/protocol.md)** — connection
|
||||||
|
parameters and endpoint-level reference
|
||||||
|
|
||||||
- **[Collections API](api/collections/list_collections.md)** - Complete collections API reference
|
## Under the hood
|
||||||
- Create and manage collections
|
|
||||||
- Auto-assign rules
|
|
||||||
- Test rules
|
|
||||||
- Bulk operations
|
|
||||||
- Device shelf mappings
|
|
||||||
|
|
||||||
## 🔌 Protocol Specifications
|
- **[Library Visibility](library-visibility.md)** — how per-user data
|
||||||
|
isolation actually works: Postgres RLS, the `app.user_id` GUC,
|
||||||
|
ScopedDB, and the rules new code must follow (the database enforces;
|
||||||
|
the app declares identity)
|
||||||
|
- **[Scanner audio metadata](scanner/audio-metadata.md)** — the sidecar
|
||||||
|
and tag extraction priority for audiobooks
|
||||||
|
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** —
|
||||||
|
how Calibre sidecar metadata becomes catalog rows
|
||||||
|
- **[Frontend Integration Notes](../FRONTEND_INTEGRATION.md)** — the
|
||||||
|
tag & contributor normalization contract (display fields vs. search
|
||||||
|
fields, and why both exist)
|
||||||
|
- **[Android App — Design & Roadmap](android-app.md)** — the native
|
||||||
|
client's design goals and platform posture
|
||||||
|
|
||||||
### Sync Protocols
|
## Standards this docs tree enforces
|
||||||
|
|
||||||
- **[Kobo Sync Protocol](api/sync/kobo-protocol.md)** - Kobo device sync protocol
|
- **[Writing UI documentation without screenshots](writing-ui-docs.md)** —
|
||||||
- Kobo markup sync
|
the standard for every UI page in this repo
|
||||||
- Library fetch
|
- **[UI control glossary](ui-controls.md)** — the canonical spelling of
|
||||||
- Authentication
|
every control the docs cite (the label gate checks it against the
|
||||||
|
templates)
|
||||||
|
- **[Contract Pins](../contract_pins.md)** — the API contract changelog;
|
||||||
|
loosening a pinned assertion requires an entry there
|
||||||
|
|
||||||
- **[KOReader Sync Protocol](api/sync/koreader-protocol.md)** - KOReader sync protocol
|
The gates behind all of it: routes ↔ docs coverage, docs ↔ Bruno
|
||||||
- Progress sync
|
parity, relative-link resolution, UI-label existence, and the RLS
|
||||||
- Metadata fetch
|
invariant test. Adding a route without a doc — or renaming a button the
|
||||||
- Conflict handling
|
docs quote — fails the suite, by design.
|
||||||
|
|
||||||
### WebSocket Protocol
|
|
||||||
|
|
||||||
- **[WebSocket API](websocket-api.md)** - Real-time sync and events
|
|
||||||
- Connection flow
|
|
||||||
- Message format
|
|
||||||
- Event types
|
|
||||||
- Authentication
|
|
||||||
|
|
||||||
## 🛠 Implementation Guides
|
|
||||||
|
|
||||||
### Library Visibility
|
|
||||||
|
|
||||||
- **[Library Visibility](library-visibility.md)** - Per-user library
|
|
||||||
visibility: the family-lens rule. New media endpoints MUST thread the
|
|
||||||
visible-library set (helpers, SQL pattern, series per-library).
|
|
||||||
|
|
||||||
### Calibre Integration
|
|
||||||
|
|
||||||
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** - Technical implementation details
|
|
||||||
- Architecture and design
|
|
||||||
- Data structures and functions
|
|
||||||
- Database schema mapping
|
|
||||||
- Testing strategy
|
|
||||||
- Error handling
|
|
||||||
- Performance considerations
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Looking for user documentation?** See the [User Portal](../user/user-guide.md)
|
**Looking for user documentation?** See the [User Portal](../user/user-guide.md).
|
||||||
**Need to deploy?** See the [Operations Portal](../operations/operations.md)
|
**Need to deploy or operate?** See the [Operations Portal](../operations/operations.md).
|
||||||
@@ -1,54 +1,88 @@
|
|||||||
# Library Visibility (family-lens rule)
|
<!-- Ground truth: database/schema/schema.sql (RLS policy block: p_media_items_*, p_podcast_feeds_*, app_can_read_collection / app_can_write_collection — labeled, idempotent, re-run every boot), internal/database/scoped.go (ScopedDB: per-statement app.user_id GUC, seed fallback = test-harness only), internal/rls (identity helpers), the three identity bridges (router JWT + cookie/device, middleware/device_auth.go), TestRlsInvariant (internal docs gate suite / API black-box). Re-verify against these when the data-access model changes. -->
|
||||||
|
|
||||||
Every endpoint that returns media metadata or content must respect
|
# Library Visibility — how per-user data isolation actually works
|
||||||
per-user **library visibility**: the `library_visibility` table maps
|
|
||||||
`(user_id, library_id, is_visible)` and defaults to **ALLOW** — every
|
|
||||||
user sees every library unless an admin explicitly hides one from them.
|
|
||||||
The point of the system is the family case: an admin hides the adult
|
|
||||||
library from a child's account, and no trace of that library (titles,
|
|
||||||
covers, series, page images) may reach the child's account.
|
|
||||||
|
|
||||||
## The rule for new endpoints
|
Every user sees every library **unless an admin hides it from them**
|
||||||
|
(`library_visibility` table, default **ALLOW** — the family-lens case:
|
||||||
|
hide the adult library from a child's account, and no trace of it —
|
||||||
|
titles, covers, series, page images — may reach that account).
|
||||||
|
|
||||||
Any endpoint that lists, filters, or details media/series/books:
|
The one thing to understand before writing any code that touches media:
|
||||||
|
|
||||||
1. Filter by the requesting user's visible-library set.
|
> **Enforcement lives in the DATABASE, as Postgres row-level security.
|
||||||
2. Reject an explicit `library_id` outside that set with **403** —
|
> The application declares *who* is asking; it never computes *what*
|
||||||
never silently ignore or fall back.
|
> they may see.**
|
||||||
3. Content endpoints (file bytes, page images) gate per item, like
|
|
||||||
`ReaderHandler.ensureItemVisible`.
|
|
||||||
|
|
||||||
If you forget, hidden libraries leak. This exact leak shipped: the
|
## The engine
|
||||||
series endpoints were name-only across ALL libraries and the PDF
|
|
||||||
thumbnail endpoint served page images of hidden books (fixed 2026-10-13,
|
|
||||||
bookhoard `431fec0`).
|
|
||||||
|
|
||||||
## Helpers — do not hand-roll
|
`database/schema/schema.sql` carries a labeled RLS block (re-run
|
||||||
|
idempotently on every boot). Governed tables — `media_items`,
|
||||||
|
`podcast_feeds`, `collections`, `collection_items` — have policies that
|
||||||
|
filter every row against the `library_visibility` mapping using the
|
||||||
|
`app.user_id` GUC: a per-transaction `SET LOCAL` declared by
|
||||||
|
`ScopedDB` (`internal/database/scoped.go`), which wraps the pool so
|
||||||
|
**every sqlc statement** runs inside a transaction that declares the
|
||||||
|
acting user.
|
||||||
|
|
||||||
- `(*MediaHandler).visibleLibrarySet` /
|
- A statement whose context carries an identity sees only that user's
|
||||||
`(*SeriesHandler).visibleLibrarySet` — returns a membership map plus
|
visible rows.
|
||||||
the id slice (map for param checks, slice for SQL `ANY()`).
|
- A statement with **no identity raises** on a governed table — the
|
||||||
- `router.visibleLibraryIDsFor` — same for SSR routes.
|
engine fails loud instead of leaking. Ungoverned tables (auth lookups,
|
||||||
- `resolveLibrary` (router/helpers.go) only honors a requested
|
settings, sessions) are unaffected and keep working.
|
||||||
`library_id` that is inside the caller's visible set.
|
- Collections use predicate functions (`app_can_read_collection`,
|
||||||
- SQL pattern (see `ListMediaItems`, `GetDistinctSeries`,
|
`app_can_write_collection`) that are PURE over their arguments — reads
|
||||||
`SearchMediaItems`):
|
are owner-or-admin, writes are owner (admin escape for SYSTEM
|
||||||
|
collections only). Future sharing = one EXISTS branch inside the
|
||||||
|
predicate, no policy migration.
|
||||||
|
|
||||||
```sql
|
## The rules for new code
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL
|
|
||||||
OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
||||||
```
|
|
||||||
|
|
||||||
## Known-safe surfaces
|
1. **New media-listing queries need NO visibility clause and NO
|
||||||
|
plumbing.** If the statement goes through `ScopedDB` with a declared
|
||||||
|
identity, the policy already filters it.
|
||||||
|
2. **The app declares identity; it never computes visibility.** Three
|
||||||
|
bridges put the identity on the request context: the JWT middleware,
|
||||||
|
the cookie/device middleware, and the KOReader/Kobo device auth
|
||||||
|
middleware. Everything downstream just carries the context.
|
||||||
|
3. **A media read on a detached `context.Background()` RAISES.** If you
|
||||||
|
need detached work (background jobs, websocket fan-in), thread the
|
||||||
|
acting identity explicitly — `rls.WithUser(ctx, userID)` — the way
|
||||||
|
scan jobs carry the library owner (first-admin fallback) and the
|
||||||
|
websocket `initial_state` carries the connecting user.
|
||||||
|
4. **NEVER**: session-scoped GUCs (pooled-connection identity bleed), a
|
||||||
|
bypass flag, or connecting the app as a superuser (superusers bypass
|
||||||
|
RLS unconditionally — the app runs as `bookhoard_srv`, and the
|
||||||
|
`db-init` compose service provisions that role at startup).
|
||||||
|
5. **Schema changes go in `schema.sql` in place**, idempotent
|
||||||
|
(`DROP .. IF EXISTS` + `CREATE`) — it re-runs on every boot. The
|
||||||
|
migration transaction declares a synthetic identity because the
|
||||||
|
schema's own dedup pass reads `media_items`.
|
||||||
|
|
||||||
- OPDS device catalogs filter by the device owner's visible libraries.
|
## How this gets audited
|
||||||
- Search (`SearchMediaItems`) is visibility-scoped.
|
|
||||||
- File download, reader page render, and page content gate per item.
|
- **`TestRlsInvariant`** — API black-box: two users, one hidden library,
|
||||||
|
every media-returning surface checked. New media-returning surfaces
|
||||||
|
must be added to it.
|
||||||
|
- **The Playwright sweep** (`tests/ui/`) — user-visible pages checked
|
||||||
|
against the coverage list.
|
||||||
|
- **`docs/contract_pins.md`** — contract-level pins (a loosening requires
|
||||||
|
a changelog entry there).
|
||||||
|
|
||||||
|
## Why the rule is written in blood
|
||||||
|
|
||||||
|
The pre-RLS app-side approach shipped a real leak (2026-10-13,
|
||||||
|
`431fec0`): series endpoints were name-only across ALL libraries and the
|
||||||
|
PDF thumbnail endpoint served page images of hidden books — every
|
||||||
|
hand-rolled filter had to be remembered at every call site, and one
|
||||||
|
forgotten clause leaked. On 2026-10-29 enforcement moved into the
|
||||||
|
database, and the invariant test went red → green to prove the old
|
||||||
|
helpers were no longer needed. If you find yourself writing a
|
||||||
|
`visible_library_ids` filter by hand, you are re-creating the bug.
|
||||||
|
|
||||||
## Series are per-library
|
## Series are per-library
|
||||||
|
|
||||||
Series are grouped by `(library_id, series)` — the same series name in
|
Series group by `(library_id, series)` — the same series name in two
|
||||||
two libraries is two entries (manga adaptations, LN splits). Do not
|
libraries is two entries (manga adaptations, LN splits). Do not re-merge
|
||||||
re-merge them cross-library. `/api/series/books` REQUIRES a visible
|
them cross-library. `/api/series/books` requires a visible `library_id`;
|
||||||
`library_id`; `/api/series` without one aggregates only the caller's
|
`/api/series` without one aggregates only the caller's visible libraries
|
||||||
visible libraries.
|
(the policy makes "visible" automatic).
|
||||||
+49
-11
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Bookhoard API
|
title: Bookhoard API
|
||||||
version: '2026.10.27'
|
version: '2026.11.02'
|
||||||
description: |
|
description: |
|
||||||
Generated from the canonical API docs (docs/developer/api/**) by
|
Generated from the canonical API docs (docs/developer/api/**) by
|
||||||
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
|
||||||
@@ -395,7 +395,7 @@ paths:
|
|||||||
}
|
}
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: Success
|
||||||
"/api/conflicts":
|
"/api/conflicts":
|
||||||
get:
|
get:
|
||||||
tags: [conflicts]
|
tags: [conflicts]
|
||||||
@@ -557,6 +557,38 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
|
"/api/devices/claim":
|
||||||
|
post:
|
||||||
|
tags: [devices]
|
||||||
|
summary: "Claim Device (Pairing Code)"
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
example: |-
|
||||||
|
{
|
||||||
|
"pairing_code": "583912"
|
||||||
|
}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
"/api/devices/claim/lookup":
|
||||||
|
post:
|
||||||
|
tags: [devices]
|
||||||
|
summary: "Look Up Pairing Code"
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
example: |-
|
||||||
|
{
|
||||||
|
"pairing_code": "583912"
|
||||||
|
}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
"/api/devices/pending":
|
"/api/devices/pending":
|
||||||
get:
|
get:
|
||||||
tags: [devices]
|
tags: [devices]
|
||||||
@@ -583,6 +615,21 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Created
|
description: Created
|
||||||
|
"/api/devices/register/cancel":
|
||||||
|
post:
|
||||||
|
tags: [devices]
|
||||||
|
summary: "Cancel Device Registration"
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
example: |-
|
||||||
|
{
|
||||||
|
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
"/api/devices/register/status":
|
"/api/devices/register/status":
|
||||||
post:
|
post:
|
||||||
tags: [devices]
|
tags: [devices]
|
||||||
@@ -598,15 +645,6 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
"/api/devices/reject/{registration_id}":
|
|
||||||
post:
|
|
||||||
tags: [devices]
|
|
||||||
summary: "Reject Device Registration"
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
"/api/devices/{deviceId}/sync/link-book":
|
"/api/devices/{deviceId}/sync/link-book":
|
||||||
post:
|
post:
|
||||||
tags: [book-matching]
|
tags: [book-matching]
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
# Testing
|
|
||||||
|
|
||||||
This document describes how to run and write tests for Bookhoard.
|
|
||||||
|
|
||||||
## Test Users and Email Domains
|
|
||||||
|
|
||||||
### Test Email Domain
|
|
||||||
|
|
||||||
All integration tests use the email domain **`@tests.bookhoard.internal`** for test users. This domain is reserved exclusively for testing and will never be used in production.
|
|
||||||
|
|
||||||
**Why this domain?**
|
|
||||||
|
|
||||||
- Clear indication it's for testing only
|
|
||||||
- Won't conflict with real user emails (which use real domains like `@gmail.com`, `@example.com`, etc.)
|
|
||||||
- Safe cleanup: Tests can delete all users with this domain without risk to production data
|
|
||||||
- If a real user somehow uses `@tests.bookhoard.internal`, they are explicitly opting into test data deletion
|
|
||||||
|
|
||||||
### Standard Test Users
|
|
||||||
|
|
||||||
The test suite creates these standard users:
|
|
||||||
|
|
||||||
1. **Admin User**
|
|
||||||
- Email: `testuser@tests.bookhoard.internal`
|
|
||||||
- Username: `testuser`
|
|
||||||
- Password: `Test@Pass123!`
|
|
||||||
- Role: `admin`
|
|
||||||
|
|
||||||
2. **Regular User**
|
|
||||||
- Email: `testregularuser@tests.bookhoard.internal`
|
|
||||||
- Username: `testregularuser`
|
|
||||||
- Password: `Test@Pass123!`
|
|
||||||
- Role: `user`
|
|
||||||
|
|
||||||
### Test Lifecycle and Cleanup
|
|
||||||
|
|
||||||
Each test run:
|
|
||||||
|
|
||||||
1. **Deletes** all existing users with `@tests.bookhoard.internal` email
|
|
||||||
2. **Creates** fresh standard test users
|
|
||||||
3. **Runs** the test with clean state
|
|
||||||
4. **Cleans up** via `t.Cleanup()` to delete users created during the test
|
|
||||||
|
|
||||||
This ensures complete test isolation - no state leaks between tests.
|
|
||||||
|
|
||||||
### Writing Tests
|
|
||||||
|
|
||||||
When writing new tests:
|
|
||||||
|
|
||||||
**✅ DO:**
|
|
||||||
|
|
||||||
- Use the standard test users from `setupTestServer(t)`
|
|
||||||
- Use `createRegularUserOnce(t, setup.DB)` for additional test users
|
|
||||||
- Let the test framework handle cleanup
|
|
||||||
- Use the `@tests.bookhoard.internal` domain if creating custom test users
|
|
||||||
|
|
||||||
**❌ DON'T:**
|
|
||||||
|
|
||||||
- Use real email domains like `@example.com` or `@gmail.com`
|
|
||||||
- Manually manage test user deletion (unless absolutely necessary)
|
|
||||||
- Assume test users persist between test runs
|
|
||||||
- Hardcode user IDs (always look up by email/username)
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Run All Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Integration Tests Only
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test-integration
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Specific Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test -v -run TestUpdateProfile ./cmd/server/tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run with Race Detection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test -race ./cmd/server/tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Organization
|
|
||||||
|
|
||||||
Tests are located in `cmd/server/tests/`:
|
|
||||||
|
|
||||||
- `test_helpers.go` - Test utilities and setup functions
|
|
||||||
- `*_test.go` - Test files organized by feature
|
|
||||||
- `user_test.go` - User management tests
|
|
||||||
- `auth_test.go` - Authentication tests
|
|
||||||
- `media_test.go` - Media item tests
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
### Key Test Helpers
|
|
||||||
|
|
||||||
- `setupTestServer(t *testing.T)` - Creates test server with clean DB state
|
|
||||||
- `createTestUserOnce(t, db)` - Gets/creates standard admin user
|
|
||||||
- `createRegularUserOnce(t, db)` - Gets/creates additional test users
|
|
||||||
- `loginWithCredentials(t, server, email, password)` - Returns auth token
|
|
||||||
|
|
||||||
## Bruno API Tests
|
|
||||||
|
|
||||||
API integration tests in `bruno/` also use the test email domain:
|
|
||||||
|
|
||||||
- Login test: `testuser@tests.bookhoard.internal`
|
|
||||||
- Register test: `testuser@tests.bookhoard.internal`
|
|
||||||
- Profile update test: `updated@tests.bookhoard.internal`
|
|
||||||
|
|
||||||
See: [Bruno Documentation](https://docs.usebruno.com/)
|
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<!-- Gate-checked by TestUIControlLabels (internal/docs/ui_labels_test.go):
|
||||||
|
every `controls` entry must exist as a literal in templates/*.templ
|
||||||
|
(or web/src for source: ts). Renames fail make test. Entry text is the
|
||||||
|
canonical spelling cited across the docs. -->
|
||||||
|
|
||||||
|
# UI controls glossary
|
||||||
|
|
||||||
|
The canonical spellings for controls cited across the UI documentation. Docs
|
||||||
|
quote the label exactly as this page lists it — one entry per control; two
|
||||||
|
spellings of one control is a defect. The `controls` block is machine-read:
|
||||||
|
when a doc first names a control, add its entry in the same commit; when a
|
||||||
|
rename lands, update the entry in the same commit and the gate goes green
|
||||||
|
again.
|
||||||
|
|
||||||
|
Entry fields:
|
||||||
|
|
||||||
|
- `label` — the exact rendered text users see
|
||||||
|
- `where` — semantic path (page → section/card), never spatial; see the
|
||||||
|
[writing standard](writing-ui-docs.md)
|
||||||
|
- `does` — what interacting with it produces (the outcome)
|
||||||
|
- `source` — optional; default `templ` (`templates/*.templ`); `ts` searches `web/src`
|
||||||
|
- `also` — optional, pipe-separated extra literal fragments to search when
|
||||||
|
the visible label is Alpine-composed rather than static (OR-checked with `label`)
|
||||||
|
- `note` — optional clarifier
|
||||||
|
|
||||||
|
Form-field labels inside a single modal (Name, Description…) are cited
|
||||||
|
inline in docs and not tracked here; this list carries the controls people
|
||||||
|
navigate by.
|
||||||
|
|
||||||
|
## Dashboard, profile & podcasts
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: Customize Dashboard
|
||||||
|
where: Library bar on the dashboard (button); opens the Customize Dashboard panel
|
||||||
|
does: Opens per-library shelf arrangement — drag order, show/hide switches, items per section.
|
||||||
|
|
||||||
|
label: Save Changes
|
||||||
|
where: Customize Dashboard panel → submit
|
||||||
|
does: Saves that library's shelf arrangement.
|
||||||
|
|
||||||
|
label: Refresh
|
||||||
|
where: Library bar on the dashboard (button)
|
||||||
|
does: Re-reads libraries without a full page reload.
|
||||||
|
note: icon button with a tooltip title
|
||||||
|
|
||||||
|
label: Create Custom Section
|
||||||
|
where: the custom-section builder page (/custom-section) → page title
|
||||||
|
does: Page where a dashboard shelf is defined by filter rules or hand-picked books.
|
||||||
|
|
||||||
|
label: Update Password
|
||||||
|
where: profile page → Change Password → submit
|
||||||
|
does: Saves the new password after live requirement checks.
|
||||||
|
|
||||||
|
label: Remove My Account
|
||||||
|
where: profile page → Danger Zone
|
||||||
|
does: Deletes the account and all personal data after confirmation; blocked for the last admin.
|
||||||
|
|
||||||
|
label: Add Feed
|
||||||
|
where: Podcasts page
|
||||||
|
does: Opens the Add Podcast Feed panel.
|
||||||
|
|
||||||
|
label: Add & Subscribe
|
||||||
|
where: Add Podcast Feed panel → submit
|
||||||
|
does: Subscribes the feed and starts watching it for new episodes.
|
||||||
|
also: Add & Subscribe
|
||||||
|
note: templ source stores the ampersand entity; the rendered label reads "Add & Subscribe"
|
||||||
|
|
||||||
|
label: Download all episodes now
|
||||||
|
where: Add Podcast Feed panel
|
||||||
|
does: Grabs the feed's back catalog immediately on subscribe.
|
||||||
|
|
||||||
|
label: Player settings
|
||||||
|
where: audio player → settings control
|
||||||
|
does: Opens the per-player preferences — Autoplay on open, Default playback speed, Resume rewind.
|
||||||
|
also: title="Settings"
|
||||||
|
|
||||||
|
label: Shortcuts & interactions
|
||||||
|
where: audio player → the ? control
|
||||||
|
does: Lists every keyboard shortcut the player supports.
|
||||||
|
also: Shortcuts & interactions
|
||||||
|
note: templ source stores the ampersand entity
|
||||||
|
|
||||||
|
label: Chapters
|
||||||
|
where: audio player → chapter-list control
|
||||||
|
does: Opens the chapter list and shows the current chapter position.
|
||||||
|
note: title attribute on the control
|
||||||
|
|
||||||
|
label: Bookmarks
|
||||||
|
where: audio player → bookmarks control
|
||||||
|
does: Opens your markers for this title; tap one to jump back.
|
||||||
|
note: title attribute on the control
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: New Collection
|
||||||
|
where: Collections page
|
||||||
|
does: Opens the create-collection modal.
|
||||||
|
note: the empty page offers the same action as "Create Your First Collection"
|
||||||
|
|
||||||
|
label: Create Your First Collection
|
||||||
|
where: Collections page, when no collections exist
|
||||||
|
does: Opens the create-collection modal.
|
||||||
|
|
||||||
|
label: Create Collection
|
||||||
|
where: create-collection modal → submit
|
||||||
|
does: Saves the new collection and returns to the Collections page.
|
||||||
|
|
||||||
|
label: Update Collection
|
||||||
|
where: edit-collection modal → submit
|
||||||
|
does: Saves your changes to the collection.
|
||||||
|
|
||||||
|
label: Static
|
||||||
|
where: create-collection modal → Type
|
||||||
|
does: Chooses a hand-picked collection — you add and remove items yourself.
|
||||||
|
|
||||||
|
label: ⚡ Smart
|
||||||
|
where: create-collection modal → Type; also shown as a badge on smart collections page and detail
|
||||||
|
does: Chooses a rules-matched collection — its membership stays current by itself.
|
||||||
|
|
||||||
|
label: Rules scope
|
||||||
|
where: create-collection modal, when Smart is chosen
|
||||||
|
does: Asks what the rules ask about — the two buttons below it.
|
||||||
|
also: 📚 Books|🎧 Podcast episodes
|
||||||
|
note: "Books" and "Podcast episodes" as the two scope buttons
|
||||||
|
|
||||||
|
label: Restore System
|
||||||
|
where: Collections page
|
||||||
|
does: Opens a pick-a-shelf modal to bring back a system collection you deleted.
|
||||||
|
|
||||||
|
label: Restore
|
||||||
|
where: Restore System Collection modal → submit
|
||||||
|
does: Brings the selected system collection back.
|
||||||
|
|
||||||
|
label: Edit Rules
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens the smart-collection rules editor for this collection.
|
||||||
|
|
||||||
|
label: Filter tree
|
||||||
|
where: smart-collection rules editor
|
||||||
|
does: The area where the group and rules are built.
|
||||||
|
note: read/write rules — the how is the filter guide
|
||||||
|
|
||||||
|
label: Apply to Collection
|
||||||
|
where: smart-collection rules editor → save
|
||||||
|
does: Saves the filter tree and makes the collection match those rules from now on.
|
||||||
|
|
||||||
|
label: Add Items
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens the item picker window.
|
||||||
|
|
||||||
|
label: Add Selected
|
||||||
|
where: item picker → footer
|
||||||
|
does: Adds everything checked in the picker to the collection.
|
||||||
|
|
||||||
|
label: Remove Selected
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens a confirm dialog to remove the checked items from the collection.
|
||||||
|
|
||||||
|
label: Search within collection...
|
||||||
|
where: collection detail page → search box
|
||||||
|
does: Filters the displayed items — nothing is removed.
|
||||||
|
|
||||||
|
label: Play as Sequence
|
||||||
|
where: collection detail page (when it has a downloaded episode)
|
||||||
|
does: Plays the collection's episodes one after another in the listening order you pick.
|
||||||
|
|
||||||
|
label: Hide finished books (reading-queue mode)
|
||||||
|
where: edit-collection modal
|
||||||
|
does: Finished books disappear from the collection's shelf.
|
||||||
|
|
||||||
|
label: Show Up Next strip
|
||||||
|
where: edit-collection modal
|
||||||
|
does: Adds this collection's Up Next strip to the podcast page.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Everywhere else
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: Filters
|
||||||
|
where: All Books / Podcasts page → toolbar
|
||||||
|
does: Opens the filter drawer for building and applying rules.
|
||||||
|
|
||||||
|
label: Find device
|
||||||
|
where: Devices page → Add New Device modal
|
||||||
|
does: Looks up the pairing code shown on the registering e-reader and renders the confirm card.
|
||||||
|
|
||||||
|
label: Pair device
|
||||||
|
where: Devices page → Add New Device modal → confirm card
|
||||||
|
does: Binds the device behind the pairing code to the signed-in account.
|
||||||
|
|
||||||
|
label: Download All
|
||||||
|
where: Podcast page → episodes area
|
||||||
|
does: Queues every episode of the feed for download.
|
||||||
|
|
||||||
|
label: Download order
|
||||||
|
where: Add Podcast Feed panel; podcast feed settings
|
||||||
|
does: Sets the ordering used when feed episodes are downloaded.
|
||||||
|
|
||||||
|
label: Match ALL / Group ANY header
|
||||||
|
where: filter drawer → group header (also smart collections → Rules and dashboard sections)
|
||||||
|
does: Sets how the group combines its rules; clicking the header flips ALL/ANY.
|
||||||
|
also: ? 'Match' : 'Group'|? 'ANY' : 'ALL'
|
||||||
|
note: label is Alpine-composed — 'Match' for the whole builder, 'Group' for nested; docs write it as MATCH ALL / MATCH ANY
|
||||||
|
|
||||||
|
label: Reset to Scanned
|
||||||
|
where: book detail → edit modal → scan information area
|
||||||
|
does: Restores scanner-extracted metadata over manual edits.
|
||||||
|
|
||||||
|
label: Auto-Scan
|
||||||
|
where: Settings page → Scanning section
|
||||||
|
does: Enables automatic periodic library scanning on the interval below it.
|
||||||
|
|
||||||
|
label: Scan Interval (seconds)
|
||||||
|
where: Settings page → Scanning section
|
||||||
|
does: Sets the seconds between automatic scans.
|
||||||
|
|
||||||
|
label: Force Rescan All Libraries
|
||||||
|
where: /admin dashboard → Quick Actions
|
||||||
|
does: Force-rescans every library — re-reads each file, re-extracts metadata with original-scan semantics, and recomputes hash identifiers. Unlike the shallow per-library and per-folder scans, nothing unchanged is skipped; this is the admin's backfill tool.
|
||||||
|
also: Scan All Libraries
|
||||||
|
note: renamed 2026-11-02 — the old label under-promised a heavy operation
|
||||||
|
|
||||||
|
label: Force Rescan
|
||||||
|
where: Admin → Libraries → a library's card row
|
||||||
|
does: Force-rescans that one library — re-reads and re-extracts every file in it. Same semantics as Force Rescan All Libraries, scoped to a single library.
|
||||||
|
also: Scan
|
||||||
|
note: renamed 2026-11-02 per owner — the label now carries the force semantics instead of hiding them
|
||||||
|
|
||||||
|
label: Scan this folder
|
||||||
|
where: Admin → Libraries → a library's panel → a folder row
|
||||||
|
does: Shallow-scans only that folder's subtree — unchanged files are skipped, and items in the library's other folders are never marked missing by a partial scan.
|
||||||
|
|
||||||
|
label: Retry
|
||||||
|
where: Admin → Libraries → a library's Processing Issues page → an issue card
|
||||||
|
does: Re-runs that one item through the scanner (a per-item scan, not a library pass). Success closes the issue; failure shows the real error.
|
||||||
|
|
||||||
|
label: Retry all
|
||||||
|
where: Admin → Libraries → a library's Processing Issues page → beside the severity cards
|
||||||
|
does: Queues every issue on the page and re-runs them one at a time, showing done/total progress. Deliberately not a whole-library scan.
|
||||||
|
|
||||||
|
label: Dismiss
|
||||||
|
where: Admin → Libraries → Processing Issues → warning and info cards only
|
||||||
|
does: Closes the issue without re-processing, after a confirm. Error-severity issues cannot be dismissed.
|
||||||
|
|
||||||
|
label: Purge All Archived
|
||||||
|
where: Admin → Archived Items → page header
|
||||||
|
does: Permanently deletes every archived item and its reading history, after a confirm. The per-item alternative is Delete.
|
||||||
|
|
||||||
|
label: Merge
|
||||||
|
where: Admin → Archived Items → rows with a matching active book
|
||||||
|
does: Folds the archived copy's reading data (progress, annotations, collections) into the active copy and removes the archived entry; per-user collisions keep the active copy's data.
|
||||||
|
|
||||||
|
label: Delete
|
||||||
|
where: Admin → Archived Items rows, and other admin destructive rows
|
||||||
|
does: Permanently removes the item and its reading history — no archive, no undo. Every Delete is confirm-guarded and states the scope.
|
||||||
|
|
||||||
|
label: Create Library
|
||||||
|
where: Admin → Libraries → page header
|
||||||
|
does: Opens the create-library modal (name, optional description, library type). Type is fixed at creation — it cannot be edited later.
|
||||||
|
|
||||||
|
label: Manage
|
||||||
|
where: Admin → Libraries → a library's card row
|
||||||
|
does: Opens that library's panel: folders, user access, processing issues, edit/delete.
|
||||||
|
|
||||||
|
label: Edit Details
|
||||||
|
where: Admin → Libraries → a library's panel
|
||||||
|
does: Edits the library's name and description. Library type is not editable.
|
||||||
|
|
||||||
|
label: Delete Library
|
||||||
|
where: Admin → Libraries → a library's panel
|
||||||
|
does: Permanently deletes the library, its folder mappings, and all book records with reading progress, highlights, notes, bookmarks, and ratings. Files on disk are not touched.
|
||||||
|
|
||||||
|
label: View Archived
|
||||||
|
where: Admin → Libraries → the archived banner
|
||||||
|
does: Opens the Archived Items page.
|
||||||
|
|
||||||
|
label: Purge Archived Now
|
||||||
|
where: Admin → Libraries → the archived banner
|
||||||
|
does: Same permanent wipe as Purge All Archived on the Archived Items page, offered inline.
|
||||||
|
|
||||||
|
label: Browse
|
||||||
|
where: Admin → Libraries → a library's panel → folder form
|
||||||
|
does: Opens the server-side folder browser: walk the filesystem, Select This Folder fills the path input.
|
||||||
|
|
||||||
|
label: User Access
|
||||||
|
where: Admin → Libraries → a library's panel → section heading
|
||||||
|
does: Per-user visibility checkboxes — checked (default) shows the library to that user; unchecked hides it everywhere for them, enforced in the database.
|
||||||
|
|
||||||
|
label: Keep both
|
||||||
|
where: Admin → Hash Conflicts → a conflict group card
|
||||||
|
does: Records the resolution and stops flagging the group; every copy stays, nothing merged or removed. For intentional duplicates.
|
||||||
|
|
||||||
|
label: Keep this copy
|
||||||
|
where: Admin → Hash Conflicts → a copy row
|
||||||
|
does: Keeps that copy, folds the other copies' reading data into it, and removes their catalog rows. Files on disk are never touched.
|
||||||
|
|
||||||
|
label: Delete files
|
||||||
|
where: Admin → All Podcasts → a feed row
|
||||||
|
does: Frees the disk and resets downloaded episodes to pending; they re-download on demand. A feed refresh will NOT re-materialize them.
|
||||||
|
|
||||||
|
label: Remove
|
||||||
|
where: Admin → All Podcasts → a feed row
|
||||||
|
does: Admin-wide feed removal — episode rows, downloaded files, and every user's subscription are deleted. Instance-wide, unlike a user's own unsubscribe.
|
||||||
|
|
||||||
|
label: + Create User
|
||||||
|
where: Admin → Users → page header
|
||||||
|
does: Opens the account-creation form (hidden until clicked). Available even when public registration is disabled; password rules mirror the live Settings.
|
||||||
|
|
||||||
|
label: Edit User Profile
|
||||||
|
where: Admin → Users → a user card → Edit
|
||||||
|
does: The admin edit modal — profile fields, role, and Change Password without requiring the user's current password.
|
||||||
|
|
||||||
|
label: Save Settings
|
||||||
|
where: Admin → Settings → top card
|
||||||
|
does: Commits the Base URL and Default Timezone together.
|
||||||
|
|
||||||
|
label: Save Scan Settings
|
||||||
|
where: Admin → Settings → Scanning card
|
||||||
|
does: Commits Auto-Scan and the Scan Interval.
|
||||||
|
|
||||||
|
label: Tunable Settings — Live
|
||||||
|
where: Admin → Settings → section heading
|
||||||
|
does: Settings that apply immediately on save, grouped by area.
|
||||||
|
|
||||||
|
label: Tunable Settings — Restart Required
|
||||||
|
where: Admin → Settings → section heading
|
||||||
|
does: Settings whose saves take effect only after the server restarts; the bucket is marked in amber.
|
||||||
|
|
||||||
|
label: Default Timezone
|
||||||
|
where: Admin → Settings → System Defaults card
|
||||||
|
does: The timezone used for any user who hasn't chosen their own; governs how dates and times render for them.
|
||||||
|
|
||||||
|
label: Resume rewind
|
||||||
|
where: audio player → player settings; profile settings
|
||||||
|
does: Rewinds playback a configured few seconds when you resume.
|
||||||
|
also: Resume Rewind
|
||||||
|
note: profile page spells it capitalized
|
||||||
|
|
||||||
|
label: Reading Direction
|
||||||
|
where: book detail → edit modal
|
||||||
|
does: Chooses the direction pages advance (publisher convention — e.g. manga).
|
||||||
|
|
||||||
|
label: Scan Info
|
||||||
|
where: book detail page → info block
|
||||||
|
does: Displays scanner-supplied notes attached to the item.
|
||||||
|
also: Scan Information
|
||||||
|
note: the editable form label in the modal reads "Scan Information"
|
||||||
|
```
|
||||||
+75
-685
@@ -1,700 +1,90 @@
|
|||||||
# WebSocket API Documentation
|
<!-- Ground truth: internal/sync/websocket.go (ConnectionManager, message
|
||||||
|
constants, broadcast fan-out + drop-on-full backpressure),
|
||||||
|
internal/handlers/websocket.go (dual auth, initial_state on connect,
|
||||||
|
30s ping / 90s read deadline, handleMessage logs-only),
|
||||||
|
internal/router/sync.go (route group), internal/services/worker.go
|
||||||
|
(scan event emitters). Re-verify against these when the wire
|
||||||
|
changes. Rewritten 2026-11-02 from source; the Feb-era version
|
||||||
|
documented client->server messages that the server never acted on. -->
|
||||||
|
|
||||||
**Base URL**: `ws://localhost:8765/ws/sync`
|
# WebSocket API (real-time events)
|
||||||
**Protocol**: WebSocket (RFC 6455)
|
|
||||||
**Authentication**: Required (JWT token or device token)
|
|
||||||
|
|
||||||
---
|
One channel, one direction: `/ws/sync` is a **server → client push feed**
|
||||||
|
for sync and scan events. It is not a command channel — clients do all
|
||||||
|
reading and writing through the REST API, and the server logs (but does
|
||||||
|
not act on) anything a client sends. The one client obligation is the
|
||||||
|
standard WebSocket pong, below.
|
||||||
|
|
||||||
## Table of Contents
|
## Connecting
|
||||||
|
|
||||||
1. [Authentication](#authentication)
|
`WS /ws/sync` — two accepted identities:
|
||||||
2. [Connection](#connection)
|
|
||||||
3. [Message Format](#message-format)
|
|
||||||
4. [Message Types](#message-types)
|
|
||||||
5. [Client Implementation Guide](#client-implementation-guide)
|
|
||||||
6. [Examples](#examples)
|
|
||||||
7. [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
---
|
| Method | How |
|
||||||
|
| --- | --- |
|
||||||
|
| User (JWT) | `?token=<jwt>` query parameter |
|
||||||
|
| Device (KOReader, Kobo) | `Authorization: Bearer <device-token>` header |
|
||||||
|
|
||||||
## Authentication
|
No token → `401`. On connect the server immediately sends one
|
||||||
|
**`initial_state`** message: the connecting identity's media items for
|
||||||
|
sync, read under that identity's own data visibility.
|
||||||
|
|
||||||
### Option 1: JWT Token (Web/Mobile Clients)
|
## Message envelope
|
||||||
|
|
||||||
Pass JWT token as query parameter:
|
Every server message is a JSON object:
|
||||||
|
|
||||||
```
|
|
||||||
ws://localhost:8765/ws/sync?token=<your_jwt_token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**How to get JWT token**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8765/api/auth/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email":"user@example.com","password":"password"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
"user": {
|
|
||||||
"id": "user-uuid",
|
|
||||||
"email": "user@example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Device Token (Kobo, KOReader, etc.)
|
|
||||||
|
|
||||||
Pass device token in Authorization header:
|
|
||||||
|
|
||||||
```
|
|
||||||
ws://localhost:8765/ws/sync?token=any-value
|
|
||||||
Authorization: Bearer <device_token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**How to get device token**:
|
|
||||||
Device tokens are generated when devices are registered via the API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Connection
|
|
||||||
|
|
||||||
### Step 1: Connect to WebSocket
|
|
||||||
|
|
||||||
**JavaScript Example**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const token = "your-jwt-token";
|
|
||||||
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token=${token}`);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("Connected to Bookhoard WebSocket");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
console.log("WebSocket closed:", event.code, event.reason);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Go Example**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
url := "ws://localhost:8765/ws/sync?token=" + token
|
|
||||||
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Dial error:", err)
|
|
||||||
}
|
|
||||||
defer ws.Close()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Python Example**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import websocket
|
|
||||||
|
|
||||||
token = "your-jwt-token"
|
|
||||||
url = f"ws://localhost:8765/ws/sync?token={token}"
|
|
||||||
|
|
||||||
def on_message(ws, message):
|
|
||||||
print(f"Received: {message}")
|
|
||||||
|
|
||||||
def on_error(ws, error):
|
|
||||||
print(f"Error: {error}")
|
|
||||||
|
|
||||||
def on_close(ws, close_status_code, close_msg):
|
|
||||||
print("Closed")
|
|
||||||
|
|
||||||
def on_open(ws):
|
|
||||||
print("Connected")
|
|
||||||
|
|
||||||
ws = websocket.WebSocketApp(
|
|
||||||
url,
|
|
||||||
on_open=on_open,
|
|
||||||
on_message=on_message,
|
|
||||||
on_error=on_error,
|
|
||||||
on_close=on_close
|
|
||||||
)
|
|
||||||
|
|
||||||
ws.run_forever()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Handle Initial State
|
|
||||||
|
|
||||||
Immediately after connecting, you'll receive the initial state:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (message.type === "initial_state") {
|
|
||||||
console.log("Initial progress:", message.data.progress);
|
|
||||||
console.log("Connected devices:", message.data.devices);
|
|
||||||
|
|
||||||
// Store initial state
|
|
||||||
initialState = message.data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Handle Real-time Updates
|
|
||||||
|
|
||||||
Listen for subsequent updates:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case "progress_update":
|
|
||||||
handleProgressUpdate(message);
|
|
||||||
break;
|
|
||||||
case "annotation_update":
|
|
||||||
handleAnnotationUpdate(message);
|
|
||||||
break;
|
|
||||||
case "conflict":
|
|
||||||
handleConflict(message);
|
|
||||||
break;
|
|
||||||
case "heartbeat":
|
|
||||||
// Server ping, ignore
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log("Unknown message type:", message.type);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleProgressUpdate(message) {
|
|
||||||
const { book_id, percentage } = message.data;
|
|
||||||
const source = message.source_device;
|
|
||||||
|
|
||||||
console.log(`Progress update for ${book_id}: ${percentage * 100}%`);
|
|
||||||
console.log(`Source: ${source.name} (${source.type})`);
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
updateProgressBar(book_id, percentage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAnnotationUpdate(message) {
|
|
||||||
const { book_id, annotation_type, data } = message.data;
|
|
||||||
const source = message.source_device;
|
|
||||||
|
|
||||||
console.log(`Annotation update for ${book_id}: ${annotation_type}`);
|
|
||||||
console.log(`Source: ${source.name}`);
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
showAnnotation(book_id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConflict(message) {
|
|
||||||
const { book_id, notification_type, conflict_id } = message.data;
|
|
||||||
|
|
||||||
console.log(`Conflict detected for ${book_id}: ${notification_type}`);
|
|
||||||
|
|
||||||
// Show conflict resolution UI
|
|
||||||
showConflictDialog(book_id, conflict_id);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Message Format
|
|
||||||
|
|
||||||
All messages follow this structure:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "message_type",
|
|
||||||
"timestamp": "2026-02-01T12:00:00Z",
|
|
||||||
"data": {
|
|
||||||
// Message-specific data
|
|
||||||
},
|
|
||||||
"source_device": {
|
|
||||||
"id": "device-uuid",
|
|
||||||
"name": "Device Name",
|
|
||||||
"type": "kobo|koreader|web|mobile"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
|
|
||||||
- `type` (string, required): Message type identifier
|
|
||||||
- `timestamp` (string, required): ISO 8601 timestamp
|
|
||||||
- `data` (object, required): Message payload
|
|
||||||
- `source_device` (object, optional): Device that sent the update (not present for initial_state)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Message Types
|
|
||||||
|
|
||||||
### 1. initial_state
|
|
||||||
|
|
||||||
**When**: Immediately after connection
|
|
||||||
|
|
||||||
**Purpose**: Send current progress for all user's books
|
|
||||||
|
|
||||||
**Data Structure**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "initial_state",
|
|
||||||
"timestamp": "2026-02-01T12:00:00Z",
|
|
||||||
"data": {
|
|
||||||
"progress": {
|
|
||||||
"<book_uuid>": {
|
|
||||||
"percentage": 0.5,
|
|
||||||
"current_page": 150,
|
|
||||||
"total_pages": 300,
|
|
||||||
"last_read": "2026-02-01T11:30:00Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"devices": {
|
|
||||||
"kobo": 2,
|
|
||||||
"koreader": 1,
|
|
||||||
"web": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
|
|
||||||
- `progress` (object): Map of book UUID → progress data
|
|
||||||
- `percentage` (number): 0.0 to 1.0
|
|
||||||
- `current_page` (number): Current page number
|
|
||||||
- `total_pages` (number): Total pages in book
|
|
||||||
- `last_read` (string): ISO 8601 timestamp of last read
|
|
||||||
- `devices` (object): Connection statistics by device type
|
|
||||||
|
|
||||||
### 2. progress_update
|
|
||||||
|
|
||||||
**When**: Any device syncs reading progress
|
|
||||||
|
|
||||||
**Purpose**: Notify all connected clients of progress change
|
|
||||||
|
|
||||||
**Data Structure**:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "progress_update",
|
"type": "progress_update",
|
||||||
"timestamp": "2026-02-01T12:00:00Z",
|
"timestamp": "2026-11-02T12:00:00Z",
|
||||||
"data": {
|
"data": { "...": "..." },
|
||||||
"book_id": "book-uuid-1",
|
"source_device": { "id": "…", "name": "Living-room Kobo", "type": "kobo" }
|
||||||
"percentage": 0.75
|
|
||||||
},
|
|
||||||
"source_device": {
|
|
||||||
"id": "device-uuid-1",
|
|
||||||
"name": "My Kobo Clara",
|
|
||||||
"type": "kobo"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fields**:
|
`source_device` is present on events that originate from a device
|
||||||
|
(progress and annotation updates), so clients can ignore their own echo.
|
||||||
- `book_id` (string): UUID of book with updated progress
|
|
||||||
- `percentage` (number): New progress value (0.0 to 1.0)
|
## Message catalog
|
||||||
- `source_device` (object): Device that sent the update
|
|
||||||
|
All ten types defined by `internal/sync/websocket.go`:
|
||||||
**Use Cases**:
|
|
||||||
|
| Type | Emitted | `data` carries |
|
||||||
- Update progress bar in real-time
|
| --- | --- | --- |
|
||||||
- Sync reading position across devices
|
| `initial_state` | Once, on connect | The connecting user's media items for sync |
|
||||||
- Update "currently reading" lists
|
| `progress_update` | On any device's progress save | `book_id`, `percentage` |
|
||||||
|
| `annotation_update` | On annotation changes (highlights, notes, bookmarks) | `book_id`, `annotation_type`, payload |
|
||||||
### 3. annotation_update
|
| `conflict` | When a sync conflict is detected | `book_id`, `notification_type`, `conflict_id` |
|
||||||
|
| `scan_progress` | During scan jobs | Job progress for the admin scan panel |
|
||||||
**When**: Any device syncs annotations, bookmarks, or highlights
|
| `scan_complete` | When a scan job finishes | Job summary |
|
||||||
|
| `scan_error` | When a scan job fails | Job error |
|
||||||
**Purpose**: Share annotations across devices
|
| `scan_cancelled` | When a scan job is cancelled | Job identity |
|
||||||
|
| `collection_updated` | When books are added to / removed from one of *your* collections | `collection_id`, `action` (`books_added` / `book_removed` / `books_removed`), affected book ids |
|
||||||
**Data Structure**:
|
| `heartbeat` | *Defined, not currently emitted* | — |
|
||||||
|
| `sync_complete` | *Defined, not currently emitted* | — |
|
||||||
```json
|
|
||||||
{
|
(`collection_updated` is currently the only message type defined as a raw
|
||||||
"type": "annotation_update",
|
string in the collections handler rather than in the sync package's
|
||||||
"timestamp": "2026-02-01T12:00:00Z",
|
constants block.)
|
||||||
"data": {
|
|
||||||
"book_id": "book-uuid-1",
|
## Keepalive
|
||||||
"annotation_type": "bookmark",
|
|
||||||
"data": {
|
The server sends a WebSocket **ping frame every 30 seconds** and expects
|
||||||
"page": 150,
|
the standard pong within a **90-second read deadline**. Browsers and
|
||||||
"text": "Great quote on page 150",
|
every mainstream WebSocket library answer pings automatically — there is
|
||||||
"chapter": 5,
|
nothing to implement. A connection that stops answering is removed.
|
||||||
"created_at": "2026-02-01T12:00:00Z"
|
|
||||||
}
|
## Fan-out and backpressure — read this before building a client
|
||||||
},
|
|
||||||
"source_device": {
|
- **Every event carries an explicit audience.** Progress, annotation,
|
||||||
"id": "device-uuid-1",
|
conflict, and collection events are delivered only to the acting
|
||||||
"name": "My Kobo Clara",
|
user's connections — the ConnectionManager has no accidental
|
||||||
"type": "kobo"
|
fan-out, and the compiler forces every emitter to name its audience
|
||||||
}
|
(2026-11-02, after cross-user delivery was found and fixed). The one
|
||||||
}
|
sanctioned instance-wide fan-out is **scan events**
|
||||||
```
|
(`BroadcastToAll`): they carry job ids and counts, never user data,
|
||||||
|
and the sitewide scan indicator in every browser runs on them.
|
||||||
**Fields**:
|
- Every connection has a bounded send queue. **If a client falls behind,
|
||||||
|
messages for that connection are skipped, not queued.** Clients must
|
||||||
- `book_id` (string): UUID of book
|
therefore treat the socket as a *hint* — on reconnect (or any gap),
|
||||||
- `annotation_type` (string): Type of annotation (bookmark, highlight, note)
|
refetch real state over REST. The fresh `initial_state` on reconnect is
|
||||||
- `data` (object): Annotation-specific data
|
designed to be that gap-healer.
|
||||||
- `page` (number): Page number
|
|
||||||
- `text` (string): Annotated text or note
|
|
||||||
- `chapter` (number): Chapter number (optional)
|
|
||||||
- `created_at` (string): ISO 8601 timestamp
|
|
||||||
- `source_device` (object): Device that created the annotation
|
|
||||||
|
|
||||||
**Use Cases**:
|
|
||||||
|
|
||||||
- Show bookmarks on all devices
|
|
||||||
- Share highlights between devices
|
|
||||||
- Display reading notes
|
|
||||||
|
|
||||||
### 4. conflict
|
|
||||||
|
|
||||||
**When**: Sync conflict is detected
|
|
||||||
|
|
||||||
**Purpose**: Notify user of conflicting updates
|
|
||||||
|
|
||||||
**Data Structure**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "conflict",
|
|
||||||
"timestamp": "2026-02-01T12:00:00Z",
|
|
||||||
"data": {
|
|
||||||
"book_id": "book-uuid-1",
|
|
||||||
"notification_type": "progress_conflict",
|
|
||||||
"conflict_id": "conflict-uuid-1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
|
|
||||||
- `book_id` (string): UUID of book with conflict
|
|
||||||
- `notification_type` (string): Type of conflict (progress_conflict, annotation_conflict)
|
|
||||||
- `conflict_id` (string): UUID of conflict record
|
|
||||||
|
|
||||||
**Use Cases**:
|
|
||||||
|
|
||||||
- Prompt user to resolve conflict
|
|
||||||
- Show conflict resolution UI
|
|
||||||
- Log conflict for manual review
|
|
||||||
|
|
||||||
### 5. heartbeat
|
|
||||||
|
|
||||||
**When**: Server sends ping every 30 seconds
|
|
||||||
|
|
||||||
**Purpose**: Keep connection alive
|
|
||||||
|
|
||||||
**Data Structure**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "heartbeat",
|
|
||||||
"timestamp": "2026-02-01T12:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Handling**: Clients should respond automatically (handled by WebSocket protocol)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client Implementation Guide
|
|
||||||
|
|
||||||
### Reconnection Strategy
|
|
||||||
|
|
||||||
Implement exponential backoff for reconnection:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
const maxReconnectAttempts = 10;
|
|
||||||
const baseReconnectDelay = 1000; // 1 second
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
const token = getAuthToken();
|
|
||||||
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token=${token}`);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("Connected");
|
|
||||||
reconnectAttempts = 0; // Reset on successful connection
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
if (reconnectAttempts < maxReconnectAttempts) {
|
|
||||||
const delay = baseReconnectDelay * Math.pow(2, reconnectAttempts);
|
|
||||||
console.log(`Reconnecting in ${delay}ms...`);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
reconnectAttempts++;
|
|
||||||
connect();
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
console.error("Max reconnection attempts reached");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = handleIncomingMessage;
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
Handle common errors:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
|
|
||||||
// Check specific error types
|
|
||||||
if (error.code === 1006) {
|
|
||||||
console.error("Abnormal closure - server may be down");
|
|
||||||
} else if (error.code === 1008) {
|
|
||||||
console.error("Policy violation - check authentication");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Message Queueing
|
|
||||||
|
|
||||||
Queue messages while disconnected:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
let messageQueue = [];
|
|
||||||
let isConnected = false;
|
|
||||||
|
|
||||||
function queueMessage(message) {
|
|
||||||
if (isConnected) {
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
} else {
|
|
||||||
messageQueue.push(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
isConnected = true;
|
|
||||||
|
|
||||||
// Send queued messages
|
|
||||||
while (messageQueue.length > 0) {
|
|
||||||
const message = messageQueue.shift();
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
isConnected = false;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Complete JavaScript Client
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
class BookhoardWebSocket {
|
|
||||||
constructor(token) {
|
|
||||||
this.token = token;
|
|
||||||
this.ws = null;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.maxReconnectAttempts = 10;
|
|
||||||
this.messageHandlers = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
const url = `ws://localhost:8765/ws/sync?token=${this.token}`;
|
|
||||||
this.ws = new WebSocket(url);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
console.log("Connected to Bookhoard");
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
this.handleMessage(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
this.reconnect();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMessage(message) {
|
|
||||||
const handler = this.messageHandlers[message.type];
|
|
||||||
if (handler) {
|
|
||||||
handler(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
on(messageType, callback) {
|
|
||||||
this.messageHandlers[messageType] = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnect() {
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
const delay = 1000 * Math.pow(2, this.reconnectAttempts);
|
|
||||||
console.log(`Reconnecting in ${delay}ms...`);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
this.connect();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const token = "your-jwt-token";
|
|
||||||
const client = new BookhoardWebSocket(token);
|
|
||||||
|
|
||||||
client.on("initial_state", (message) => {
|
|
||||||
console.log("Initial state received:", message.data);
|
|
||||||
updateUIWithInitialState(message.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("progress_update", (message) => {
|
|
||||||
console.log("Progress updated:", message.data);
|
|
||||||
updateProgressBar(message.data.book_id, message.data.percentage);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("annotation_update", (message) => {
|
|
||||||
console.log("Annotation updated:", message.data);
|
|
||||||
showNotification("New annotation from " + message.source_device.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on("conflict", (message) => {
|
|
||||||
console.log("Conflict detected:", message.data);
|
|
||||||
showConflictDialog(message.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.connect();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Connection Fails
|
|
||||||
|
|
||||||
**Problem**: Can't connect to WebSocket
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check JWT token is valid
|
|
||||||
2. Verify server is running: `curl http://localhost:8765/health`
|
|
||||||
3. Check firewall settings
|
|
||||||
4. Verify WebSocket URL format
|
|
||||||
|
|
||||||
### Connection Drops Frequently
|
|
||||||
|
|
||||||
**Problem**: WebSocket disconnects unexpectedly
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check network stability
|
|
||||||
2. Verify server keepalive settings
|
|
||||||
3. Implement reconnection logic (see above)
|
|
||||||
4. Check server logs for errors
|
|
||||||
|
|
||||||
### No Messages Received
|
|
||||||
|
|
||||||
**Problem**: Connected but no messages
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check onmessage handler is registered
|
|
||||||
2. Verify initial_state message received
|
|
||||||
3. Test with manual progress update via API
|
|
||||||
4. Check browser console for errors
|
|
||||||
|
|
||||||
### Authentication Errors
|
|
||||||
|
|
||||||
**Problem**: 401 Unauthorized
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify JWT token is not expired
|
|
||||||
2. Check token has correct claims (user_id)
|
|
||||||
3. For device tokens, verify Authorization header format
|
|
||||||
4. Regenerate token via `/api/auth/login`
|
|
||||||
|
|
||||||
### High Memory Usage
|
|
||||||
|
|
||||||
**Problem**: Client memory increases over time
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Clean up old messages
|
|
||||||
2. Don't store entire message history
|
|
||||||
3. Use weak references for large data
|
|
||||||
4. Implement message pagination
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
1. **Debounce UI Updates**: Don't update DOM on every message
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const debouncedUpdate = debounce(updateUI, 100);
|
|
||||||
client.on("progress_update", (message) => {
|
|
||||||
debouncedUpdate(message);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Use Web Workers** for heavy processing
|
|
||||||
3. **Limit Message History**: Keep only last N messages
|
|
||||||
4. **Virtual Scrolling**: For large lists of updates
|
|
||||||
5. **Connection Pooling**: Reuse connections across tabs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
1. **Use WSS in Production**: Always use secure WebSocket (wss://) in production
|
|
||||||
2. **Validate Tokens**: Always verify JWT tokens on server
|
|
||||||
3. **Rate Limiting**: Implement message rate limiting per connection
|
|
||||||
4. **Input Sanitization**: Sanitize all message data before displaying
|
|
||||||
5. **Origin Checks**: Validate WebSocket origin on server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
|
|
||||||
- GitHub Issues: [Bookhoard Repository]
|
|
||||||
- Documentation: [Bookhoard Docs]
|
|
||||||
- API Reference: [Bookhoard API Docs]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: 2026-02-01
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Protocol**: WebSocket (RFC 6455)
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Writing UI documentation without screenshots
|
||||||
|
|
||||||
|
Bookhoard's UI documentation uses no screenshots. This page is the standard
|
||||||
|
for writing them. The flagship example of the style is the
|
||||||
|
[filter system documentation](../user/filters/index.md): one system taught
|
||||||
|
once, the places it appears listed as a *pattern*, samples for every field in
|
||||||
|
a [generated reference](../user/filters/fields.md) that cannot drift from the
|
||||||
|
UI.
|
||||||
|
|
||||||
|
## Why no screenshots
|
||||||
|
|
||||||
|
Screenshots fail in every way the rest of this repo's rot is already gated
|
||||||
|
against:
|
||||||
|
|
||||||
|
- **They rot silently.** No gate can diff pixels against the running UI. A
|
||||||
|
renamed button makes the screenshot wrong and nothing fails.
|
||||||
|
- **They cannot be verified.** Shown text can: this app's UI is
|
||||||
|
server-rendered from `templates/*.templ`, so every stable label and heading
|
||||||
|
is a literal in the source — checkable, like routes and API examples are.
|
||||||
|
- **They die with a redesign.** Text keyed to *function* survives a
|
||||||
|
re-layout. Text keyed to a layout dies with its redesign.
|
||||||
|
- **They exclude** (screen readers, text search) and bloat diffs.
|
||||||
|
|
||||||
|
The one thing screenshots do better — "which button is it?" — text does via
|
||||||
|
**exact quoted labels** plus a semantic path, and the label gate then holds
|
||||||
|
that text against the source.
|
||||||
|
|
||||||
|
## The layering — what is load-bearing
|
||||||
|
|
||||||
|
Write every UI page in this order. The top layers are why users don't get
|
||||||
|
lost when the UI changes:
|
||||||
|
|
||||||
|
| # | Layer | Content | Lifespan |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | **The pattern** | What the thing *is*, what is invariant, every place the same pattern appears | Redesign-proof |
|
||||||
|
| 2 | **Function inventory** | What the user *can do*, phrased without control names | Redesign-proof |
|
||||||
|
| 3 | **How-to with examples** | Numbered flows: current label → the outcome it produces | Survives most changes |
|
||||||
|
| 4 | **Current labels** | Exactly quoted, bold | Gate-checked today |
|
||||||
|
| 5 | **Generated references** | Enumerables regenerated from source with a currency test | Cannot drift |
|
||||||
|
|
||||||
|
Labels are **tips, not the map**. Layer 3 may quote a label; layer 1 must
|
||||||
|
make the page followable even if that label is renamed next week.
|
||||||
|
|
||||||
|
## The page template
|
||||||
|
|
||||||
|
1. **The pattern first.** The concept, its invariants, and everywhere it
|
||||||
|
appears — "one system, three places". Never page cartography.
|
||||||
|
2. **Function inventory.** Capabilities without control names: "you can pin
|
||||||
|
any narrowing to a named filter you can reopen later".
|
||||||
|
3. **How-to with examples.** Each step: the label of what you interact with,
|
||||||
|
then the outcome ("the count at the bottom shows how many books will
|
||||||
|
match"). An example beats an adjective.
|
||||||
|
4. **Findability note.** One line telling the reader how to re-locate the
|
||||||
|
feature *by function again* after any UI change — deliberately
|
||||||
|
label-independent ("it's the drawer opened from any books or podcasts
|
||||||
|
list — the only place whose header can read Match ALL").
|
||||||
|
5. **Current labels.** Quoted exactly as rendered, one canonical spelling
|
||||||
|
per control ([arbitrated by the glossary](ui-controls.md)).
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- **Semantic placement, never spatial.** No "top-right" or "at the bottom of
|
||||||
|
the screen" talk. "Devices page → the **Register Device** button" survives
|
||||||
|
any re-layout.
|
||||||
|
- **One canonical label per control.** Two spellings of the same control is
|
||||||
|
a defect, not a synonym.
|
||||||
|
- **Status updates get a what-it-means table**, not a color description.
|
||||||
|
- **Screenshots are never load-bearing.** Default is none.
|
||||||
|
|
||||||
|
## Tone by audience
|
||||||
|
|
||||||
|
- **User Guide (`docs/user/`) — plain language, deliberately.** Short
|
||||||
|
sentences. Everyday words. No developer jargon; any term the reader
|
||||||
|
genuinely needs (say, *rule*) is explained once, in place, then used
|
||||||
|
consistently. The reader is assumed to know books, while the app's own
|
||||||
|
words are the only technical vocabulary allowed.
|
||||||
|
- **Administration (`docs/admin/`) — plain language, one layer deeper.**
|
||||||
|
Same bones as the user docs: task-first, what to click for what, exact
|
||||||
|
quoted labels. Added: every control also states what the click *actually
|
||||||
|
does* — semantics, cost, and consequences — before the reader clicks it.
|
||||||
|
The canonical example: **Force Rescan All Libraries** re-reads and
|
||||||
|
re-extracts every file and recomputes hashes, while the other scan
|
||||||
|
buttons are shallow scans that skip unchanged files — an admin should
|
||||||
|
know that before clicking, not after. Every setting states what it is,
|
||||||
|
what it does, what it means when that is confusing, and its valid range.
|
||||||
|
No API-client detail: request and response schemas live in the developer
|
||||||
|
docs. The calibration in one line: **user = what you see, admin = what
|
||||||
|
it does, developer = how it works.**
|
||||||
|
- **Developer section — source-pinned and wire-accurate.** The
|
||||||
|
developer tier is *how it works*: wire formats, data access, pipeline
|
||||||
|
mechanics, and enforced rules, every claim grounded in source the way
|
||||||
|
admin docs ground behavior. External software (device installs,
|
||||||
|
third-party tooling) is linked to its project's documentation, not
|
||||||
|
paraphrased. (Decided 2026-11-02, closing the earlier open question.)
|
||||||
|
|
||||||
|
## Generate what has a registry
|
||||||
|
|
||||||
|
Anything enumerable gets generated from source with a currency test, not
|
||||||
|
hand-typed. The precedent: every filter field is
|
||||||
|
[generated](../user/filters/fields.md) by `TestFieldReferenceDocIsCurrent`
|
||||||
|
from the same registry the menus render — the doc cannot disagree with the
|
||||||
|
UI. Hand-typed lists drift fastest; when no registry exists yet, say so in
|
||||||
|
the page and lean on the pattern layers.
|
||||||
|
|
||||||
|
## The gates
|
||||||
|
|
||||||
|
- `TestUIControlLabels` (`internal/docs/ui_labels_test.go`): every control in
|
||||||
|
the [glossary](ui-controls.md) must exist as a literal in
|
||||||
|
`templates/*.templ` (or `web/src` for TS-rendered surfaces, marked
|
||||||
|
`source: ts`). Renames fail `make test` in the same commit as the rename.
|
||||||
|
- `TestDocsRelativeLinks`: every relative link in the docs tree resolves to
|
||||||
|
a real file.
|
||||||
|
|
||||||
|
Naming a new control in prose? Add its glossary entry in the same commit.
|
||||||
|
Form labels inside one modal (Name, Description…) are cited inline and not
|
||||||
|
glossary-tracked; the glossary carries the controls people navigate by —
|
||||||
|
buttons, tabs, badges, page titles.
|
||||||
|
|
||||||
|
## The process rule
|
||||||
|
|
||||||
|
**A UI change is not finished until the docs audit runs.** Moved card,
|
||||||
|
renamed button, restructured page — all of it triggers: labels still
|
||||||
|
resolve, links still resolve, affected pages re-read against the new
|
||||||
|
surface. The pattern layers keep users oriented between audits; the audit
|
||||||
|
keeps layers 3–5 honest.
|
||||||
|
|
||||||
|
## Promises need approval
|
||||||
|
|
||||||
|
Docs never promise future behavior on the owner's behalf. No "coming soon",
|
||||||
|
"under consideration", "in a future release", or any other forward-looking
|
||||||
|
commitment gets written without the owner explicitly approving that exact
|
||||||
|
promise — and anything speculative found in the docs gets reported, not
|
||||||
|
quietly kept and not quietly deleted. (Rule origin, 2026-11-02: the owner
|
||||||
|
ruled out "Kindle and reMarkable are under consideration" and a "Custom
|
||||||
|
Calibre Columns (Future)" section — no such plans existed — while the
|
||||||
|
Operations guide's pre-existing "Coming Soon" placeholders were reported
|
||||||
|
and knowingly kept for the future operations-docs pass.)
|
||||||
|
|
||||||
|
## What this does not replace
|
||||||
|
|
||||||
|
The filter documentation (and the older filtering overview) is hands-off
|
||||||
|
exemplar territory — read it as the model, never restyle it here. New UI
|
||||||
|
surfaces (scanning, devices, settings, podcasts, the reader, collections)
|
||||||
|
are written this way from the start.
|
||||||
@@ -623,6 +623,6 @@ This is a great example of how user feedback can improve implementation design!
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [User Guide: Calibre Integration](../../user/calibre-integration.md)
|
- [User Guide: Calibre Integration](../user/calibre-integration.md)
|
||||||
- [Implementation Plan](../../CALIBRE_OPF_IMPLEMENTATION.md)
|
- The original implementation plan file was removed from the repo when this page absorbed its content.
|
||||||
- [Scanner Source Code](../../internal/services/media_scanner.go)
|
- The scanner implementation lives in `internal/services/` (not hyperlinked — the docs site serves only docs).
|
||||||
+58
-81
@@ -13,57 +13,74 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
- **Device Setup**
|
- **Device Setup**
|
||||||
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
|
- [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)
|
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
|
||||||
|
- [Android App](user/devices/android-app.md)
|
||||||
|
|
||||||
- **Sync Configuration**
|
- **Sync Configuration**
|
||||||
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
||||||
|
|
||||||
- **Frontend & Settings** (Coming Soon)
|
- **Filters**
|
||||||
- Frontend Guide
|
- [Filters — the complete guide](user/filters/index.md) - rules, groups (ALL/ANY), saved filters, smart collections
|
||||||
- User Areas Guide
|
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
|
||||||
- Settings Guide
|
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
|
||||||
- Admin Guide
|
|
||||||
|
- **Guides**
|
||||||
|
- [Reader Guide](user/reader-guide.md) - reading, highlights, notes
|
||||||
|
- [Library Browsing](user/library-browsing.md) - browsing and searching your library
|
||||||
|
- [Collections](user/collections.md) - manual and ⚡ smart shelves, books and episodes
|
||||||
|
- [Podcasts](user/podcasts.md) - feeds, downloads, listening
|
||||||
|
- [Dashboard](user/dashboard.md)
|
||||||
|
- [Themes](user/themes.md)
|
||||||
|
- [Calibre Integration](user/calibre-integration.md)
|
||||||
|
- [Profile Guide](user/profile-guide.md)
|
||||||
|
|
||||||
|
### 🛡 For Administrators
|
||||||
|
|
||||||
|
**[Admin Guide](admin/admin-guide.md)** - running a Bookhoard instance: every admin page, what each control actually does
|
||||||
|
|
||||||
|
- **Administration**
|
||||||
|
- [Admin Dashboard](admin/dashboard.md) - stats, Force Rescan All Libraries, scan progress
|
||||||
|
- [Libraries](admin/libraries.md) - create libraries, folders, user access, scanning
|
||||||
|
- [Processing Issues](admin/library-processing-issues.md) - items the scanner couldn't process
|
||||||
|
- [Archived Items](admin/archived-items.md) - missing files, history-preserving archive
|
||||||
|
- [All Podcasts](admin/podcasts.md) - the maintenance view for every feed
|
||||||
|
- [Hash Conflicts](admin/hash-conflicts.md) - duplicate content resolution
|
||||||
|
- [Users](admin/users.md) - accounts, roles, password resets
|
||||||
|
- [System Settings](admin/settings.md) - every setting: key, default, range, meaning
|
||||||
|
- [Scanning](admin/scanning.md) - shallow vs. force: what every scan button does
|
||||||
|
|
||||||
### 👨💻 For Developers
|
### 👨💻 For Developers
|
||||||
|
|
||||||
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
|
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
|
||||||
|
|
||||||
- **API Documentation**
|
- **API Documentation** — every endpoint is documented individually; the links below are entry points
|
||||||
- [Complete API Reference](developer/api-reference.md) - Monolithic REST API reference (1,300+ lines)
|
- [API Reference overview](developer/api/api-reference.md) - index of all endpoint docs
|
||||||
- [Split Endpoint Docs](developer/api/api-reference.md) - Individual endpoint docs
|
- [Collections API](developer/api/collections/list_collections.md) - collections management API
|
||||||
- [Collections API](developer/api/collections/list_collections.md) - Collections management API
|
- [Media Items API](developer/api/media-items/list_media_items.md) - media item operations including bulk operations and downloads
|
||||||
- [Admin API](developer/api/admin/) - User management (admin operations)
|
- [Devices API](developer/api/devices/list_devices.md) - Device sync and shelf management
|
||||||
- [Media Items API](developer/api/media-items/) - Media item operations including bulk operations and downloads
|
- [Conflicts API](developer/api/conflicts/list_conflicts.md) - Sync conflict resolution endpoints
|
||||||
- [Devices API](developer/api/devices/) - Device sync and shelf management
|
- [Queue API](developer/api/queue/list_all_queue_items.md) - Sync queue management endpoints
|
||||||
- [Conflicts API](developer/api/conflicts/) - Sync conflict resolution endpoints
|
- [Scanner API](developer/api/scanner/overview.md) - Library scanning and automated watch mode (admin)
|
||||||
- [Queue API](developer/api/queue/) - Sync queue management endpoints
|
- [KOReader API](developer/api/koreader/get_library.md) - KOReader sync protocol endpoints
|
||||||
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
- [Kobo API](developer/api/kobo/sync_from_server.md) - Kobo sync protocol endpoints
|
||||||
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
|
- [Admin API](developer/api/admin/list_users.md) - User management (admin operations); content-duplicate cleanup lives in [hash conflicts](developer/api/admin/hash_conflicts_api.md)
|
||||||
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
|
|
||||||
- [WebSocket API](developer/api/websocket/) - Real-time sync events
|
|
||||||
|
|
||||||
- **Protocol Specifications**
|
- **Protocol Specifications**
|
||||||
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader 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)
|
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
|
||||||
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
|
||||||
|
- **Writing UI docs?** Read [Writing UI documentation without screenshots](developer/writing-ui-docs.md) first.
|
||||||
|
|
||||||
### 🔧 For Operations
|
### 🔧 For Operations
|
||||||
|
|
||||||
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
|
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
|
||||||
|
|
||||||
- **Deployment**
|
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
|
||||||
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
|
|
||||||
|
|
||||||
- **Maintenance** (Coming Soon)
|
|
||||||
- Deployment Guide
|
|
||||||
- Maintenance Guide
|
|
||||||
- Monitoring Guide
|
|
||||||
|
|
||||||
### 🤝 For Contributors
|
### 🤝 For Contributors
|
||||||
|
|
||||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||||
|
|
||||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
- [Development Guide](contributing/development.md) - Architecture, setup, testing, and the project rules
|
||||||
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,11 +88,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
|
|
||||||
| Want to... | Go to |
|
| Want to... | Go to |
|
||||||
| -------------------- | ---------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------- |
|
||||||
| **Get started** | [README.md](../README.md) - Project overview and quick start |
|
| **Get started** | [Development Guide](contributing/development.md) - Architecture and quick start |
|
||||||
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
||||||
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
|
| **Use the API** | [API Reference overview](developer/api/api-reference.md) |
|
||||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
| **Deploy Bookhoard** | [Operations → Troubleshooting](operations/troubleshooting.md) |
|
||||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
| **Contribute code** | [Development Guide](contributing/development.md) |
|
||||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||||
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
||||||
|
|
||||||
@@ -87,65 +104,25 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
|
|
||||||
| Question | Answer |
|
| Question | Answer |
|
||||||
| --------------------------- | ------------------------------------------------------ |
|
| --------------------------- | ------------------------------------------------------ |
|
||||||
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
|
|
||||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-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 |
|
| ...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) |
|
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||||
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
||||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
| ...use the API? | [API Reference overview](developer/api/api-reference.md) |
|
||||||
|
| ...write UI docs? | [Writing UI documentation](developer/writing-ui-docs.md) |
|
||||||
| ...contribute code? | [Development Guide](contributing/development.md) |
|
| ...contribute code? | [Development Guide](contributing/development.md) |
|
||||||
|
|
||||||
### "Where is..."
|
|
||||||
|
|
||||||
| Information | Location |
|
|
||||||
| ---------------------- | ----------------------------------------------------------- |
|
|
||||||
| ...features list? | [README.md](../README.md) |
|
|
||||||
| ...database schema? | [database/schema/schema.sql](../database/schema/schema.sql) |
|
|
||||||
| ...API endpoints? | [API Reference](developer/api-reference.md) |
|
|
||||||
| ...secrets config? | [.env.example](../.env.example) |
|
|
||||||
| ...operational config? | [docker-compose.yml](../docker-compose.yml) |
|
|
||||||
| ...deployment issues? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Common Workflows
|
|
||||||
|
|
||||||
### Set up a new device
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
### Integrate with Bookhoard API
|
|
||||||
|
|
||||||
1. Start with [API Reference](developer/api-reference.md)
|
|
||||||
2. Explore [Collections API](developer/api/collections/list_collections.md)
|
|
||||||
3. Review [WebSocket API](developer/websocket-api.md)
|
|
||||||
4. Test with [bruno/](../bruno/) collections
|
|
||||||
|
|
||||||
### Deploy to production
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Contributing to Documentation
|
## 📝 Contributing to Documentation
|
||||||
|
|
||||||
When adding new features:
|
When adding new features:
|
||||||
|
|
||||||
1. **User-facing features** → Update relevant User docs
|
1. **User-facing features** → Update the relevant User docs
|
||||||
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
|
2. **API endpoints** → Update the endpoint docs under `developer/api/` (start from the [API Reference overview](developer/api/api-reference.md))
|
||||||
3. **Backend changes** → Update [Development Guide](contributing/development.md)
|
3. **Backend changes** → Update the [Development Guide](contributing/development.md)
|
||||||
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
4. **Deployment changes** → Update the [Operations Portal](operations/operations.md)
|
||||||
|
5. **New UI controls in docs** → Add them to the [UI controls glossary](developer/ui-controls.md)
|
||||||
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards.
|
6. **UI appearances in docs** → Follow [Writing UI documentation without screenshots](developer/writing-ui-docs.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: August 2026
|
|
||||||
**Bookhoard Version**: 1.0
|
|
||||||
@@ -15,19 +15,19 @@ Welcome to the Bookhoard operations documentation. This section contains guides
|
|||||||
|
|
||||||
### Additional Deployment Guides
|
### Additional Deployment Guides
|
||||||
|
|
||||||
**[Deployment Guide](deployment.md)** - Comprehensive deployment guide
|
**Deployment Guide** - Comprehensive deployment guide
|
||||||
|
|
||||||
- _Coming Soon_
|
- _Coming Soon_
|
||||||
|
|
||||||
## 🛠️ Maintenance
|
## 🛠️ Maintenance
|
||||||
|
|
||||||
**[Maintenance Guide](maintenance.md)** - Ongoing operations and maintenance
|
**Maintenance Guide** - Ongoing operations and maintenance
|
||||||
|
|
||||||
- _Coming Soon_
|
- _Coming Soon_
|
||||||
|
|
||||||
## 📊 Monitoring
|
## 📊 Monitoring
|
||||||
|
|
||||||
**[Monitoring Guide](monitoring.md)** - Monitoring and alerting
|
**Monitoring Guide** - Monitoring and alerting
|
||||||
|
|
||||||
- _Coming Soon_
|
- _Coming Soon_
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Admin Guide
|
|
||||||
|
|
||||||
**Coming Soon**
|
|
||||||
|
|
||||||
This guide will cover:
|
|
||||||
|
|
||||||
- User management
|
|
||||||
- Library management
|
|
||||||
- System monitoring
|
|
||||||
- Analytics and reporting
|
|
||||||
- Security settings
|
|
||||||
- Backup and restore
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_In the meantime, check out our [Operations Portal](../operations/)_
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Admin Library Management
|
|
||||||
|
|
||||||
## Adding Library Folders
|
|
||||||
|
|
||||||
When creating or managing a library, you can add folders containing your media files (ebooks, comics, manga).
|
|
||||||
|
|
||||||
### Using the Folder Browser
|
|
||||||
|
|
||||||
The admin library page includes a folder browser to help you select folders on the server:
|
|
||||||
|
|
||||||
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; it fills the path input, then click **Add**
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- The folder browser only shows directories (not files)
|
|
||||||
- Path traversal is protected (cannot access parent directories with `..`)
|
|
||||||
- Only admin users can browse the filesystem
|
|
||||||
|
|
||||||
### Manual Entry
|
|
||||||
|
|
||||||
Alternatively, you can manually type the full server path if you know it:
|
|
||||||
|
|
||||||
```
|
|
||||||
/home/user/books
|
|
||||||
/media/external/ebooks
|
|
||||||
/var/lib/manga
|
|
||||||
```
|
|
||||||
@@ -139,7 +139,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
|
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)
|
2. Trigger a scan from the admin interface's scan controls, or let watch mode pick up the changed files automatically
|
||||||
3. Bookhoard imports all 500 books with:
|
3. Bookhoard imports all 500 books with:
|
||||||
- Correct titles and authors
|
- Correct titles and authors
|
||||||
- Series information (e.g., "Harry Potter #2")
|
- Series information (e.g., "Harry Potter #2")
|
||||||
@@ -168,7 +168,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
**Steps**:
|
**Steps**:
|
||||||
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
||||||
2. In Bookhoard, trigger a rescan:
|
2. In Bookhoard, trigger a rescan:
|
||||||
- Via the **Scanner API** (`POST /api/scanner/scan`), or
|
- Via the admin interface's scan controls, or
|
||||||
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
||||||
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
|
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)
|
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
|
||||||
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically)
|
3. **Force rescan**: Trigger a rescan from the admin interface to re-import all metadata (watch mode also picks up changed files automatically)
|
||||||
4. **Check logs**: Review Bookhoard logs for parsing errors
|
4. **Check logs**: Review Bookhoard logs for parsing errors
|
||||||
|
|
||||||
### Incorrect Metadata
|
### Incorrect Metadata
|
||||||
@@ -248,16 +248,6 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
|
|
||||||
## Advanced Usage
|
## Advanced Usage
|
||||||
|
|
||||||
### Custom Calibre Columns (Future)
|
|
||||||
|
|
||||||
Bookhoard may support Calibre custom columns in a future release. This would allow you to import:
|
|
||||||
- Read status
|
|
||||||
- Favorite flags
|
|
||||||
- Custom metadata fields
|
|
||||||
- User-defined categories
|
|
||||||
|
|
||||||
Stay tuned for updates!
|
|
||||||
|
|
||||||
### OPDS Integration
|
### OPDS Integration
|
||||||
|
|
||||||
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
|
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
|
||||||
@@ -332,11 +322,10 @@ file is never touched if anything fails (temp file + atomic rename).
|
|||||||
- [Calibre Website](https://calibre-ebook.com/)
|
- [Calibre Website](https://calibre-ebook.com/)
|
||||||
- [Calibre User Manual](https://manual.calibre-ebook.com/)
|
- [Calibre User Manual](https://manual.calibre-ebook.com/)
|
||||||
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
|
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
|
||||||
- [Bookhoard GitHub](https://github.com/yourusername/bookhoard)
|
|
||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
|
|
||||||
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
|
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
|
||||||
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
|
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
|
||||||
- **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details
|
- **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details
|
||||||
- **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/yourusername/bookhoard/issues)
|
- **Issues**: Report bugs or request features on your repository's issue tracker
|
||||||
+101
-25
@@ -1,42 +1,118 @@
|
|||||||
# Collections
|
# Collections
|
||||||
|
|
||||||
Collections allow you to organize books across multiple libraries.
|
A collection is a personal shelf you name yourself. You put your books and
|
||||||
|
podcast episodes on it, and it appears as a section on your dashboard.
|
||||||
|
|
||||||
## Viewing Collections
|
That is the whole idea. Everything below is variations on it.
|
||||||
|
|
||||||
### From Collections Page
|
## The one pattern, four flavors
|
||||||
|
|
||||||
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
|
Every collection behaves the same way on the outside: it has a name, an icon,
|
||||||
|
a color, and a list of items. The flavors differ in how items *join*:
|
||||||
|
|
||||||
### From Dashboard
|
| Flavor | How items join | Right for |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Static** | You pick each item by hand | Curated lists — "books for the trip", "new house ideas" |
|
||||||
|
| **⚡ Smart** (books) | Rules match books live, forever | "everything by Asimov", "still not started", "over 400 pages" |
|
||||||
|
| **⚡ Smart** (podcast episodes) | Rules match episodes live — and they can play back-to-back | "catch up on my feeds", "episodes to finish" |
|
||||||
|
| **System** (built-in) | Bookhoard maintains them | Continue Reading, Recently Added, Recently Read, Not Started, Continue Series |
|
||||||
|
|
||||||
When viewing a specific library's dashboard, collections only show books from that library. Clicking "View All" preserves this context and shows only books from that library in the collection.
|
A collection holds **books and podcast episodes** — mix them if you like.
|
||||||
|
|
||||||
### Library Filtering
|
The **⚡ Smart** badge means the same thing everywhere it appears: *membership
|
||||||
|
is matched by rules right now — always current, nothing to refresh*.
|
||||||
|
|
||||||
- **No filter** (`/collections/favorites`): Shows all books across all libraries
|
## Creating a collection
|
||||||
- **With filter** (`/collections/favorites?library_id=xxx`): Shows only books from the specified library
|
|
||||||
|
|
||||||
## Creating Collections
|
1. Open the **Collections** page (sidebar navigation)
|
||||||
|
2. Click **New Collection**
|
||||||
|
3. Choose the **Type**:
|
||||||
|
- **Static** — pick items by hand
|
||||||
|
- **⚡ Smart** — match by rules, always live
|
||||||
|
4. If you picked **⚡ Smart**, choose the **Rules scope**: **📚 Books** or
|
||||||
|
**🎧 Podcast episodes**
|
||||||
|
5. Fill in the rest: **Name**, **Description**, an **Icon** you can search or
|
||||||
|
pick, and a **Color**
|
||||||
|
6. Click **Create Collection**
|
||||||
|
|
||||||
1. Go to **Collections** (sidebar navigation)
|
Starting from a completely empty account? The button **Create Your First
|
||||||
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
|
Collection** does the same thing.
|
||||||
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
|
## Adding items by hand (Static collections)
|
||||||
|
|
||||||
Each collection in the list has icon buttons on its card:
|
1. Open the collection from the **Collections** page
|
||||||
|
2. Click **Add Items**
|
||||||
|
3. Pick the tab for what you want:
|
||||||
|
- **📚 Books** — searchable by the **Search books...** box, and the
|
||||||
|
**Author** and **Genre** boxes
|
||||||
|
- **🎧 Episodes** — the episodes of the feeds you subscribe to
|
||||||
|
4. Click one or more items — a checkmark marks what you picked; the bottom
|
||||||
|
of the window counts them for you
|
||||||
|
5. Click **Add Selected**
|
||||||
|
|
||||||
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save.
|
To take items out, check them on the collection page and click
|
||||||
- **Delete** - Removes the collection after a confirmation prompt.
|
**Remove Selected** (it asks first).
|
||||||
|
|
||||||
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections.
|
While checking what is in the collection, the **Search within collection...**
|
||||||
|
box narrows the view — it never removes anything.
|
||||||
|
|
||||||
### Dashboard Sections
|
## Rules (how ⚡ Smart collections work)
|
||||||
|
|
||||||
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)).
|
A smart collection keeps its own list of rules, the same rules that power the
|
||||||
|
filter drawer. On the collection's page, click **Edit Rules** to open the
|
||||||
|
rules editor: build your groups and rules in the **Filter tree**, then click
|
||||||
|
**Apply to Collection**. From that point the collection matches those rules
|
||||||
|
by itself — add a book that matches and it appears without you doing
|
||||||
|
anything.
|
||||||
|
|
||||||
|
Rules live in one guide, so this page does not repeat them. The full how-to
|
||||||
|
walks through groups, ALL/ANY, and value picking in
|
||||||
|
[Filters — the complete guide](filters/index.md), ready-made examples are in
|
||||||
|
the [cookbook](filters/cookbook.md), and
|
||||||
|
[every filter field](filters/fields.md) lists what you can ask about.
|
||||||
|
|
||||||
|
*Finding it again later:* open any collection's page and look for the
|
||||||
|
**Edit Rules** button; the **⚡ Smart** badge in the page title marks a
|
||||||
|
collection that is already matching.
|
||||||
|
|
||||||
|
## Playing an episode collection
|
||||||
|
|
||||||
|
When a collection holds a downloaded episode, it grows a **Play as Sequence**
|
||||||
|
button: the episodes play one after another. Next to it, the order picker
|
||||||
|
decides the sequence — **Feed order**, **Feed order (reversed)**,
|
||||||
|
**Oldest first (pub date)**, or **Newest first (pub date)**. Your choice is
|
||||||
|
remembered on the collection.
|
||||||
|
|
||||||
|
## Changing a collection
|
||||||
|
|
||||||
|
The pencil button on a collection card, or the edit control on the
|
||||||
|
collection page, opens the edit form:
|
||||||
|
|
||||||
|
- Change the **Name**, **Description**, **Icon**, **Color**
|
||||||
|
- **Hide finished books (reading-queue mode)** — finished books drop off
|
||||||
|
this shelf, which keeps a to-read list short
|
||||||
|
- **Show Up Next strip** — show the collection's Up Next strip where it is
|
||||||
|
used
|
||||||
|
|
||||||
|
Click **Update Collection** to save. Deleting a collection asks first, and
|
||||||
|
deleting only removes the shelf — never the books or episodes inside it.
|
||||||
|
|
||||||
|
## System collections
|
||||||
|
|
||||||
|
The five built-in shelves — **Continue Reading**, **Recently Added**,
|
||||||
|
**Recently Read**, **Not Started**, **Continue Series** — work like any
|
||||||
|
collection on the outside, but Bookhoard decides their membership by what
|
||||||
|
happens while you read. They appear on your dashboard automatically.
|
||||||
|
|
||||||
|
Deleted one and want it back? Click **Restore System** on the **Collections**
|
||||||
|
page, pick the shelf from the list, and click **Restore**.
|
||||||
|
|
||||||
|
## Your dashboard shelves
|
||||||
|
|
||||||
|
Collections appear as sections on your dashboard, and you choose which ones
|
||||||
|
show and in what order from the dashboard's own settings. That page's
|
||||||
|
details live in [Dashboard](dashboard.md).
|
||||||
|
|
||||||
|
*Finding your way back:* **Collections** is the sidebar page whose top has
|
||||||
|
the only **New Collection** button — from a dashboard shelf's **View All**,
|
||||||
|
the collection detail page is the place with **Add Items**.
|
||||||
+45
-35
@@ -1,52 +1,62 @@
|
|||||||
# Dashboard
|
# Dashboard
|
||||||
|
|
||||||
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
|
The dashboard is your home screen: horizontal shelves ("sections") of your
|
||||||
|
books, one row after another. What each shelf contains depends on the library
|
||||||
|
you are looking at — including the **All Libraries** view, which mixes
|
||||||
|
everything you can see.
|
||||||
|
|
||||||
## Sections
|
## The three kinds of section
|
||||||
|
|
||||||
### Smart Sections
|
| Kind | What fills it |
|
||||||
|
| --- | --- |
|
||||||
|
| **System shelves** | Bookhoard maintains them from your reading: **Continue Reading**, **Recently Added**, **Recently Read**, **Not Started**, **Continue Series** |
|
||||||
|
| **Your collections** | Every collection you create appears as its own shelf |
|
||||||
|
| **Custom sections** | Shelves you define yourself — by filter rules or by hand-picking books |
|
||||||
|
|
||||||
Smart sections are automatically generated based on your reading activity:
|
System shelves and collections are covered by their own guides (see
|
||||||
|
[Sync Guide](sync-guide.md) for what "continue reading" means across devices,
|
||||||
|
and [Collections](collections.md) for the shelves you build). This page is
|
||||||
|
about arranging the rows.
|
||||||
|
|
||||||
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
|
### This Week
|
||||||
- **In Progress**: Books you've started (progress > 0%)
|
|
||||||
- **Recently Added**: Newest items added to this library
|
|
||||||
- **Recently Read**: Books you've completed (100% progress)
|
|
||||||
- **Not Started**: Books you haven't read yet
|
|
||||||
|
|
||||||
### User Collections
|
The dashboard also shows a small **This Week** summary — your reading time
|
||||||
|
and activity for the week so far. Click it to expand the detail.
|
||||||
|
|
||||||
Your collections appear as sections on the dashboard. To show or hide a collection's section:
|
## Customizing your shelves
|
||||||
|
|
||||||
1. Select the library in the **Library** bar
|
1. Pick the library in the **Library** bar (a specific library, or
|
||||||
2. Click the **Customize Dashboard** icon button
|
**All Libraries**) — settings are saved per library
|
||||||
3. Toggle the collection on or off
|
2. Click the **Customize Dashboard** button in the Library bar
|
||||||
4. Click "Save Changes"
|
3. In the **Customize Dashboard** panel: drag sections into the order you
|
||||||
|
want, switch sections on or off, and set how many items each section
|
||||||
|
shows (10–50)
|
||||||
|
4. Click **Save Changes**
|
||||||
|
|
||||||
### Customizing Your Dashboard
|
The **Refresh** button next to it re-reads your libraries without a full
|
||||||
|
page reload.
|
||||||
|
|
||||||
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
|
## Custom sections
|
||||||
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.
|
A custom section is a shelf whose contents you define — either with the same
|
||||||
|
filter rules the **Filters** drawer uses, or by picking the books yourself.
|
||||||
|
You build one on the **Create Custom Section** page (at `/custom-section` on
|
||||||
|
your server); once saved it behaves like any other shelf and can be ordered
|
||||||
|
and hidden with everything else.
|
||||||
|
|
||||||
### Library Switching
|
## Reading and listening from the dashboard
|
||||||
|
|
||||||
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.
|
Point at a shelf and chevron buttons appear to scroll it. Every item carries
|
||||||
|
a direct action: **Read** opens the web reader, **Listen** opens the audio
|
||||||
|
player. If the same book has conflicting progress from two devices, the item
|
||||||
|
grows a resolve action that takes you to conflict resolution — see the
|
||||||
|
[Sync Guide](sync-guide.md).
|
||||||
|
|
||||||
### Keyboard Navigation
|
## Keyboard and touch
|
||||||
|
|
||||||
- **Tab**: Navigate between sections and books
|
- **Tab** moves between shelves and books; **Enter** opens the focused book
|
||||||
- **Arrow Keys**: Scroll carousels horizontally
|
- **Arrow keys** scroll the focused shelf sideways
|
||||||
- **Enter**: Open selected book
|
- On touch: swipe a shelf to scroll it, tap a cover to open it
|
||||||
|
|
||||||
Hovering a carousel shows chevron buttons on either side for scrolling.
|
*Finding your way back:* the dashboard is the page with the **Library** bar —
|
||||||
|
the only place with the **Customize Dashboard** button.
|
||||||
### Touch Gestures (Mobile)
|
|
||||||
|
|
||||||
- **Swipe**: Drag carousel left/right to scroll
|
|
||||||
- **Tap**: Open book details
|
|
||||||
@@ -1,40 +1,47 @@
|
|||||||
# Kobo Device Setup Guide
|
# Kobo Device Setup Guide
|
||||||
|
|
||||||
> ## 🚧 Coming Soon
|
There are two ways to use a Kobo with Bookhoard, and they can be chosen
|
||||||
>
|
independently per device:
|
||||||
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
|
|
||||||
|
|
||||||
## Using a Kobo With Bookhoard Today
|
| Way | Status | What you get |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **KOReader on the Kobo** | Works today, fully | Reading position, bookmarks, highlights, notes, OPDS wireless book delivery |
|
||||||
|
| **Stock Kobo firmware (native sync)** | Server support is complete; device-side enablement is the remaining piece | Reading position and stats, bookmarks/highlights/notes, shelf mappings (your collections as Kobo shelves), automatic EPUB → KEPUB conversion, and the Kobo's own sync experience |
|
||||||
|
|
||||||
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.
|
## Using a Kobo today
|
||||||
|
|
||||||
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
|
**KOReader runs on Kobo hardware** and syncs fully with Bookhoard right
|
||||||
|
now — the [KOReader Setup Guide](koreader-setup.md) walks the whole path
|
||||||
|
(plugin install, server approval, OPDS delivery, troubleshooting).
|
||||||
|
|
||||||
## What's Planned for Native Kobo Sync
|
## About native Kobo sync
|
||||||
|
|
||||||
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard:
|
The server side of the native protocol is implemented and documented: the
|
||||||
|
Kobo sync and initialization endpoints, bookmark and markup (highlight and
|
||||||
|
note) sync, shelf mappings that expose your collections as Kobo shelves,
|
||||||
|
and the KEPUB sidecar configuration used for conversion. What remains is
|
||||||
|
the device-side rollout, so stock-firmware sync is not yet something to
|
||||||
|
rely on for daily use — this guide will gain a full setup walkthrough when
|
||||||
|
that lands.
|
||||||
|
|
||||||
- **Reading position sync** — percentages, pages, and reading statistics
|
Nothing about native sync takes anything away from KOReader: the two can
|
||||||
- **Bookmarks, highlights, and notes** — synced with the web and other devices
|
coexist, and a KOReader setup keeps working either way.
|
||||||
- **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
|
|
||||||
|
|
||||||
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Should I buy a Kobo to use with Bookhoard today?**
|
**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.
|
A: Yes, via KOReader — it is the supported path today and covers sync plus
|
||||||
|
wireless book delivery.
|
||||||
|
|
||||||
**Q: What happens to my KOReader setup when native sync arrives?**
|
**Q: Will I have to redo anything when native sync lands?**
|
||||||
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
|
A: No — KOReader keeps working; native sync just adds the stock-firmware
|
||||||
|
option.
|
||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
||||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||||
- [Bookhoard API Reference](../../developer/api-reference.md)
|
- Kobo sync protocol (for developers): [Kobo API docs](../../developer/api/kobo/sync_from_server.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,82 @@
|
|||||||
|
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, pairing
|
||||||
|
flow) + the server-side koreader endpoints. Re-verify against the plugin
|
||||||
|
source when the plugin changes. KOReader ITSELF is out of scope: this
|
||||||
|
guide supports the plugin only, and installation is delegated to the
|
||||||
|
KOReader project's own documentation (README #installation, verified
|
||||||
|
2026-11-02 — per-device wiki pages for Android, Kindle, Kobo,
|
||||||
|
PocketBook, reMarkable, and more). -->
|
||||||
|
|
||||||
# KOReader Device Setup Guide
|
# KOReader Device Setup Guide
|
||||||
|
|
||||||
This guide will help you set up KOReader on your e-reader device to sync with Bookhoard.
|
This guide will help you set up KOReader on your e-reader device to sync with
|
||||||
|
Bookhoard — reading position, bookmarks, highlights, notes, and even your
|
||||||
|
ratings, reviews, and reading time. One scope note up front: this guide is
|
||||||
|
about the **Bookhoard sync plugin**. Installing KOReader itself is a
|
||||||
|
different project's documentation, covered below.
|
||||||
|
|
||||||
## What is KOReader?
|
## What is KOReader?
|
||||||
|
|
||||||
KOReader is an open-source e-reader application that supports a wide range of e-reader devices including:
|
KOReader is an open-source e-reader application that supports a wide range of
|
||||||
|
e-reader devices including:
|
||||||
|
|
||||||
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
||||||
- Kobo devices (Clara, Aura, Nia, etc.)
|
- Kobo devices (Clara, Aura, Nia, etc.)
|
||||||
- PocketBook devices
|
- PocketBook devices
|
||||||
|
|
||||||
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there.
|
It also runs on Android tablets and phones — though on Android the dedicated
|
||||||
|
[Bookhoard app](android-app.md) is the better option.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before you begin, make sure you have:
|
Before you begin, make sure you have:
|
||||||
|
|
||||||
- ✅ A Bookhoard instance running and accessible on your network
|
- ✅ A Bookhoard instance running and accessible on your network
|
||||||
- ✅ A web browser logged in to your Bookhoard account (for device approval)
|
- ✅ A web browser logged in to your Bookhoard account (to enter the pairing code)
|
||||||
- ✅ A KOReader-compatible e-reader device
|
- ✅ KOReader installed and running on your e-reader (see next section)
|
||||||
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
|
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
|
||||||
|
|
||||||
## Installing KOReader
|
## Installing KOReader
|
||||||
|
|
||||||
### Kindle Devices
|
One honest note first: **Bookhoard supports the Bookhoard sync plugin —
|
||||||
|
not KOReader itself.** Getting KOReader onto an e-reader usually involves
|
||||||
|
device-specific steps (jailbreaks, alternative launchers, one-click
|
||||||
|
packages) that vary by model and firmware. That's the KOReader project's
|
||||||
|
domain, not ours — we can't tell you how to modify your particular device,
|
||||||
|
and we won't pretend otherwise with half-remembered instructions.
|
||||||
|
|
||||||
1. **Install KOReader**
|
The good news: KOReader documents this properly. Their installation guide
|
||||||
- Download the latest KOReader release from [koreader.rocks](https://koreader.rocks/)
|
routes you to model-specific steps for Android, Kindle, Kobo, PocketBook,
|
||||||
- Extract the `koreader` folder to your Kindle's root directory
|
reMarkable, and more:
|
||||||
- Safely eject your Kindle from your computer
|
|
||||||
|
|
||||||
2. **Launch KOReader**
|
**→ [KOReader installation guide](https://github.com/koreader/koreader#installation)**
|
||||||
- On your Kindle, go to Home → Settings → Device Options → Personalize Your Kindle
|
|
||||||
- Select `koreader` from the launcher options
|
|
||||||
- Alternatively, you can use the `linkjail` method to access KOReader directly
|
|
||||||
|
|
||||||
3. **Enable Wi-Fi**
|
Once KOReader itself is installed and running, everything below applies,
|
||||||
- In KOReader, tap the network icon in the top menu
|
whatever device you're on.
|
||||||
- Connect to your Wi-Fi network
|
|
||||||
|
|
||||||
### Kobo Devices
|
|
||||||
|
|
||||||
1. **Install KOReader**
|
|
||||||
- Download the latest KOReader Kobo package from [koreader.rocks](https://koreader.rocks/)
|
|
||||||
- Copy the `koreader` folder to your Kobo's `.adds/` directory
|
|
||||||
- Safely eject your Kobo from your computer
|
|
||||||
|
|
||||||
2. **Launch KOReader**
|
|
||||||
- Eject and disconnect your Kobo
|
|
||||||
- Kobo will restart and you'll see KOReader as an option
|
|
||||||
- Alternatively, create a shortcut on your home screen
|
|
||||||
|
|
||||||
3. **Enable Wi-Fi**
|
|
||||||
- In KOReader, tap the network icon
|
|
||||||
- Connect to your Wi-Fi network
|
|
||||||
|
|
||||||
### PocketBook Devices
|
|
||||||
|
|
||||||
1. **Install KOReader**
|
|
||||||
- Download the PocketBook version from [koreader.rocks](https://koreader.rocks/)
|
|
||||||
- Copy to your device and install via the package manager
|
|
||||||
|
|
||||||
2. **Launch KOReader**
|
|
||||||
- Open KOReader from your apps menu
|
|
||||||
- Enable Wi-Fi in the network settings
|
|
||||||
|
|
||||||
## Connecting KOReader to Bookhoard
|
## Connecting KOReader to Bookhoard
|
||||||
|
|
||||||
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.
|
Setup is done **on the server**: your e-reader shows a pairing code, and you
|
||||||
|
type it into your own account's Devices page — no usernames, passwords, or
|
||||||
|
tokens to type on the device.
|
||||||
|
|
||||||
### Step 1: Install the Bookhoard Plugin
|
### Step 1: Install the Bookhoard Plugin
|
||||||
|
|
||||||
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
Installation is a git clone straight into your KOReader `plugins/` folder —
|
||||||
2. Copy it to your KOReader `plugins/` directory
|
no download-and-copy step:
|
||||||
3. Restart KOReader
|
|
||||||
|
```bash
|
||||||
|
cd koreader/plugins # .adds/koreader/plugins on Kobo
|
||||||
|
git clone https://git.linuxhg.com/Bookhoard/bookhoard.koplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart KOReader.
|
||||||
|
|
||||||
|
**Updating later** is the same, smaller: in the `bookhoard.koplugin` folder,
|
||||||
|
`git pull`, then restart KOReader.
|
||||||
|
|
||||||
### Step 2: Point the Plugin at Your Server
|
### Step 2: Point the Plugin at Your Server
|
||||||
|
|
||||||
1. Open KOReader, tap the **wrench icon** at the top
|
1. Open KOReader, tap the **wrench icon** at the top (the tools menu)
|
||||||
2. Find and tap **Bookhoard sync**
|
2. Find and tap **Bookhoard sync**
|
||||||
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
||||||
|
|
||||||
@@ -87,34 +86,120 @@ Setup is done **on the server**: you approve the device from the Bookhoard web i
|
|||||||
|
|
||||||
Use your server's LAN IP (or domain if you have one configured).
|
Use your server's LAN IP (or domain if you have one configured).
|
||||||
|
|
||||||
### Step 3: Approve the Device in Bookhoard
|
### Step 3: Register the Device
|
||||||
|
|
||||||
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation)
|
1. In the same **Bookhoard sync** menu, tap **Register device**
|
||||||
2. Refresh the page — your device appears under **Pending Device Registrations**
|
2. Your e-reader displays a **6-digit pairing code**
|
||||||
3. Click **Approve** to connect the device
|
3. On your computer or phone, open Bookhoard — logged into **your** account —
|
||||||
|
and go to the **Devices** page (sidebar navigation)
|
||||||
|
4. Type the code into the **Pair a Device** field and click **Find device**,
|
||||||
|
then confirm on the card that appears
|
||||||
|
|
||||||
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.
|
The code is the only way to claim the registration, and only someone holding
|
||||||
|
the device can read it — so the device always lands in the account of the
|
||||||
|
person who registered it. The plugin waits on the device and finishes by
|
||||||
|
itself the moment you confirm. Once paired, its credentials are stored
|
||||||
|
automatically, sync events are armed, and Bookhoard's OPDS catalog is added to
|
||||||
|
your KOReader — no further configuration is needed.
|
||||||
|
|
||||||
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again.
|
> **Note:** Pairing codes expire after 5 minutes. If yours expires
|
||||||
|
> ("Registration expired"), just tap **Register device** again for a fresh
|
||||||
|
> one. Dismissing the waiting dialog on the device cancels the attempt.
|
||||||
|
|
||||||
### Auth Token (Advanced)
|
### Auth Token (Advanced)
|
||||||
|
|
||||||
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.
|
The Devices page shows each KOReader device's **Auth Token**. You normally
|
||||||
|
never need it (the plugin receives it automatically during pairing), but it
|
||||||
|
can be re-entered manually in the plugin settings if you're moving a setup
|
||||||
|
between devices or debugging.
|
||||||
|
|
||||||
|
## The Bookhoard sync menu
|
||||||
|
|
||||||
|
Everything the plugin can do lives in one place: **wrench icon → Bookhoard
|
||||||
|
sync**. The menu changes shape depending on whether you are connected.
|
||||||
|
|
||||||
|
### Before you're connected
|
||||||
|
|
||||||
|
- **Server URL** — your server's address
|
||||||
|
- **Register device** — starts the pairing flow above
|
||||||
|
|
||||||
|
### Once connected — one-tap actions
|
||||||
|
|
||||||
|
| Menu item | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| **Push progress from this device** | Force-push this device's position to the server |
|
||||||
|
| **Pull progress from server** | Force-pull the server's position onto this device |
|
||||||
|
| **Sync rating, review & time now** | Push your rating, review, and accumulated reading time for the current book immediately |
|
||||||
|
| **Setup OPDS catalog** | (Re-)adds Bookhoard's catalog to KOReader's OPDS list — normally done for you at approval |
|
||||||
|
| **Device info** | Shows this device's ID and the server it talks to |
|
||||||
|
| **Disconnect** | Forgets the device's credentials (after a confirmation) |
|
||||||
|
|
||||||
|
### Automatically push progress
|
||||||
|
|
||||||
|
This toggle is the on/off switch for background sync — **it starts off**, so
|
||||||
|
after pairing, turn it on to get automatic syncing. With it on, progress
|
||||||
|
pushes as you read and on close/suspend; the plugin may prompt about turning
|
||||||
|
Wi-Fi on at those moments depending on your device.
|
||||||
|
|
||||||
|
Two related settings sit under it:
|
||||||
|
|
||||||
|
- **Periodically sync every # pages** — how many page turns between updates
|
||||||
|
(default 50; set 0 to disable periodic updates)
|
||||||
|
- **Sync mode** — **Immediate** pushes right away, **Checkpoint** batches
|
||||||
|
changes for slower connections or battery saving
|
||||||
|
|
||||||
|
### Sync behavior — who wins when states differ
|
||||||
|
|
||||||
|
These two settings decide what the device does when its progress and the
|
||||||
|
server's disagree, without you opening the Conflicts page:
|
||||||
|
|
||||||
|
- **Sync to a newer state** — the server has *newer* progress than this
|
||||||
|
device (default **Prompt**: the device asks before adopting it; alternatives
|
||||||
|
are **Silently** or **Never**)
|
||||||
|
- **Sync to an older state** — this device has *newer* progress than the
|
||||||
|
server (default **Never**: the device never rolls itself back)
|
||||||
|
|
||||||
|
Set both to **Silently** for hands-off syncing, or leave the defaults if you
|
||||||
|
want a say whenever progress would move.
|
||||||
|
|
||||||
|
### What to sync
|
||||||
|
|
||||||
|
Seven toggles, all on by default, each controlling one kind of data:
|
||||||
|
|
||||||
|
- **Reading progress**
|
||||||
|
- **Bookmarks**
|
||||||
|
- **Highlights**
|
||||||
|
- **Notes**
|
||||||
|
- **Ratings** — your book ratings made in KOReader
|
||||||
|
- **Book review** — your review text
|
||||||
|
- **Reading time** — time-spent statistics
|
||||||
|
|
||||||
|
Turn off anything you'd rather keep local; the rest keeps flowing both ways.
|
||||||
|
|
||||||
## What Syncs
|
## What Syncs
|
||||||
|
|
||||||
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
|
Once connected and enabled, the following sync in both directions between
|
||||||
|
KOReader and Bookhoard (web, Android, and other devices), each governed by
|
||||||
|
its toggle above:
|
||||||
|
|
||||||
- **Reading position** — percentage, chapter, and EPUB CFI where available
|
- **Reading position** — percentage, chapter, and EPUB CFI where available
|
||||||
- **Bookmarks**
|
- **Bookmarks**
|
||||||
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes
|
- **Highlights** — including highlight colors, mapped between the web and
|
||||||
|
KOReader palettes
|
||||||
- **Notes** — standalone and attached to highlights
|
- **Notes** — standalone and attached to highlights
|
||||||
|
- **Ratings, review, and reading time** — pushed to the server (push any
|
||||||
|
time from the menu with **Sync rating, review & time now**)
|
||||||
|
|
||||||
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.
|
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
|
## OPDS Wireless Book Delivery
|
||||||
|
|
||||||
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.
|
At approval the plugin adds Bookhoard's OPDS catalog to KOReader (and
|
||||||
|
**Setup OPDS catalog** re-adds it any time), so you can browse and download
|
||||||
|
books wirelessly — no USB cable needed.
|
||||||
|
|
||||||
### Browse and Download Books
|
### Browse and Download Books
|
||||||
|
|
||||||
@@ -129,20 +214,33 @@ Once your device is approved, the plugin also registers Bookhoard's OPDS catalog
|
|||||||
- **PDF**: Fixed-layout documents
|
- **PDF**: Fixed-layout documents
|
||||||
- **CBZ**: Comic book archives
|
- **CBZ**: Comic book archives
|
||||||
|
|
||||||
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
|
Books downloaded via OPDS are automatically matched to your library, so
|
||||||
|
their progress syncs from the first page.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Pending Registration Never Appears
|
### Pending Registration Never Appears
|
||||||
|
|
||||||
**Problem**: You entered the Server URL, but no pending registration shows in Bookhoard
|
**Problem**: You entered the Server URL, but no pairing code shows on the device
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
1. Verify the Server URL is correct (no trailing path — just the base address)
|
1. Verify the Server URL is correct (no trailing path — just the base address)
|
||||||
2. Make sure KOReader is connected to Wi-Fi
|
2. Make sure KOReader is connected to Wi-Fi
|
||||||
3. Check the Bookhoard server is reachable from the device's network
|
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
|
4. Registrations expire after 5 minutes — tap **Register device** again
|
||||||
|
|
||||||
|
### Pairing Code Not Accepted
|
||||||
|
|
||||||
|
**Problem**: The Devices page says "No waiting device matches that code"
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
1. Re-read the code on the device — it refreshes on every **Register device** tap
|
||||||
|
2. Check you typed all 6 digits (spacing doesn't matter)
|
||||||
|
3. Codes expire after 5 minutes — register again on the device and type the new code
|
||||||
|
4. "Pairing code already used" means the device already paired (check the device list); otherwise register again
|
||||||
|
5. Too many attempts lock new tries for a minute — wait and retry
|
||||||
|
|
||||||
### Connection Refused
|
### Connection Refused
|
||||||
|
|
||||||
@@ -155,17 +253,24 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
|||||||
- Ensure the device is on the same Wi-Fi network as the server
|
- Ensure the device is on the same Wi-Fi network as the server
|
||||||
- Use the server's LAN IP instead of `localhost`
|
- Use the server's LAN IP instead of `localhost`
|
||||||
|
|
||||||
### Sync Not Working After Approval
|
### Sync Not Working After Pairing
|
||||||
|
|
||||||
**Problem**: Device shows as approved but changes don't appear in Bookhoard
|
**Problem**: Device shows as paired but changes don't appear in Bookhoard
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
- Trigger a manual sync from the plugin menu
|
- **Automatically push progress** defaults to off — turn it on in the menu, or push once with **Push progress from this device**
|
||||||
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
|
- 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
|
- Verify the book appears as an unlinked book for the device and link it if needed
|
||||||
- Check Bookhoard server logs for errors
|
- Check Bookhoard server logs for errors
|
||||||
|
|
||||||
|
### Turning on auto-sync asks about Wi-Fi
|
||||||
|
|
||||||
|
If enabling **Automatically push progress** shows a message about
|
||||||
|
**"Action when Wi-Fi is off"**, open KOReader's Network settings and set
|
||||||
|
that action to **turn on** — auto-sync needs permission to bring Wi-Fi up by
|
||||||
|
itself.
|
||||||
|
|
||||||
### Conflicts Detected
|
### Conflicts Detected
|
||||||
|
|
||||||
**Problem**: Sync conflicts when reading the same book on multiple devices
|
**Problem**: Sync conflicts when reading the same book on multiple devices
|
||||||
@@ -174,23 +279,23 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
|||||||
|
|
||||||
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
|
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
|
2. Review the progress reported by each device
|
||||||
3. Choose which device's progress to keep
|
3. Choose a resolution: keep one side's progress, keep the server's, or let the furthest progress win
|
||||||
|
|
||||||
|
For day-to-day control of the same question on the device itself, use the
|
||||||
|
**Sync behavior** settings (newer state / older state).
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|
||||||
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
|
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
|
||||||
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
||||||
3. **Device Authorization**: Only approve pending registrations you initiated
|
3. **Claim only codes you can see**: a pairing code is readable only on the
|
||||||
|
registering device's screen — never claim a code someone merely told you
|
||||||
|
if you can't see the device showing it
|
||||||
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
|
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
|
||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [KOReader Documentation](https://github.com/koreader/koreader)
|
- [KOReader](https://github.com/koreader/koreader) — [installation](https://github.com/koreader/koreader#installation) · [user guide](https://koreader.rocks/user_guide/)
|
||||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=276) — the project's official Mobileread subforum
|
||||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||||
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: August 2026
|
|
||||||
**Bookhoard Version**: 1.0
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Filter cookbook
|
||||||
|
|
||||||
|
Real questions, exact clicks. Every recipe uses the same builder described in
|
||||||
|
[the guide](index.md) — same fields, same rules, wherever filters live.
|
||||||
|
|
||||||
|
Recipes marked **collection** become smart collections that stay current forever; the rest are
|
||||||
|
drawer filters you can save with a name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "New chapters of 7th Son, automatically" — collection
|
||||||
|
|
||||||
|
1. **Collections** → open (or create) a collection → **Rules**.
|
||||||
|
2. The scope is already *Episodes*. Add a rule: **Feed** → **in** → pick
|
||||||
|
*7th Son: Book One* (it becomes a chip — hit the × if you pick wrong).
|
||||||
|
3. Add a second rule: **Title** → **contains** → `Chapter`.
|
||||||
|
4. The header should read **MATCH ALL** (it does by default — every rule must match).
|
||||||
|
5. **Apply to Collection**. Done: new chapters appear on their own, and the listening order
|
||||||
|
starts at **Feed order (reversed)** — chapter 1 first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Lewis Carroll or George Orwell" — drawer
|
||||||
|
|
||||||
|
1. **Filters** on the All Books page. Add: **Author** → **contains** → `Carroll`.
|
||||||
|
2. Add a second rule: **Author** → **contains** → `Orwell`.
|
||||||
|
3. Click the group header **MATCH ALL** once — it flips to **MATCH ANY** (or click the `AND`
|
||||||
|
chip between the two rules, same effect).
|
||||||
|
4. **Apply Filters**. Either author is enough.
|
||||||
|
|
||||||
|
*Flip it back to ALL and you'd get only books they both wrote — which is none, and the counter
|
||||||
|
will tell you so before you apply.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Long fantasy books I haven't started" — drawer
|
||||||
|
|
||||||
|
1. **Genre** → **is** → `fantasy`.
|
||||||
|
2. **Page count** → **at least** → `400`.
|
||||||
|
3. **My reading** → **Reading status** → **is** → *Unfinished* (or your app's wording — the
|
||||||
|
status list is the fixed dropdown on that field).
|
||||||
|
4. **MATCH ALL** stays on: all three must hold.
|
||||||
|
5. Apply — then **Save** it as *"Big unread fantasy"* and it's in the Load menu forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Downloaded podcast episodes I haven't played" — collection
|
||||||
|
|
||||||
|
1. A collection with an Episodes scope (see the first recipe — drop the Title rule if you want
|
||||||
|
whole feeds).
|
||||||
|
2. **Downloaded** → **is true**.
|
||||||
|
3. **Played** → **is false**.
|
||||||
|
4. **MATCH ALL** on. **Apply to Collection**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Added to my library this month" — drawer
|
||||||
|
|
||||||
|
1. **Date added** → **within the last** → `30` (the days box — the field's page in
|
||||||
|
[Every filter field](fields.md) has its exact questions).
|
||||||
|
2. Optionally **MATCH ALL** with **Library** → **in** → your audiobooks library.
|
||||||
|
3. Save as *"New arrivals"*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "A dashboard shelf that builds itself" — dashboard
|
||||||
|
|
||||||
|
1. Create a collection with the rules above (or open one).
|
||||||
|
2. On the dashboard, add a section pointed at that collection.
|
||||||
|
3. The shelf re-fills itself as your library changes — no maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing a filtered view
|
||||||
|
|
||||||
|
Apply any filter, then copy the address bar. The filter rides in the URL — send it to another
|
||||||
|
user of your server and they open the same narrowed view (they see it through their own
|
||||||
|
libraries, not yours).
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)
|
||||||
|
from the filter registry — the same source the real menus render. DO NOT EDIT by hand:
|
||||||
|
regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->
|
||||||
|
|
||||||
|
# Every filter field
|
||||||
|
|
||||||
|
This is the complete list of everything the filters can ask about, generated straight from the
|
||||||
|
same source the app's menus render — it cannot drift from what you see in the UI.
|
||||||
|
|
||||||
|
Filters come in two worlds:
|
||||||
|
|
||||||
|
- **Books** — rules about books and your reading of them.
|
||||||
|
- **Episodes** — rules about podcast episodes (feeds, downloaded, played).
|
||||||
|
|
||||||
|
Every rule is **a field + a question + your values**. The "Ask" column is the question;
|
||||||
|
the "Your values" column is what you provide. The **default** question is what a new rule
|
||||||
|
starts with.
|
||||||
|
|
||||||
|
## Books
|
||||||
|
|
||||||
|
### Book facts
|
||||||
|
|
||||||
|
**Title** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Subtitle** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Description** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Summary** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Language** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Page count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Chapter count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Total characters** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Community rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date added** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date imported** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Web URL** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Metadata notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**ISBN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**ASIN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**File size** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File availability** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Explicit** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Abridged** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Manually edited** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Cover** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Added by** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
### Comics & manga
|
||||||
|
|
||||||
|
**Series** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Series number** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Series count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Volume** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Format group** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Reflowable** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Fixed layout** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Black & white** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Manga type** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Reading direction** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### My reading
|
||||||
|
|
||||||
|
**Reading status** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
|
||||||
|
**Progress percent** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Last read** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**My rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| unrated | — |
|
||||||
|
|
||||||
|
**Has review** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Highlights** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Note text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Highlight text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Time spent (minutes)** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**Pages read** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| in any | is in at least one of the collections you pick |
|
||||||
|
| in none | — |
|
||||||
|
|
||||||
|
### People
|
||||||
|
|
||||||
|
**Author** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Narrator** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Contributor** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
**Publisher** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Imprint** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Edition** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published year** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published date** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Topics
|
||||||
|
|
||||||
|
**Genre** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Tags** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Story arc** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Age rating** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
## Podcast episodes
|
||||||
|
|
||||||
|
### Book facts
|
||||||
|
|
||||||
|
**Title** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Description** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Language** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Chapter count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date added** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date imported** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File size** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File availability** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Explicit** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
### Comics & manga
|
||||||
|
|
||||||
|
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### Episodes
|
||||||
|
|
||||||
|
**Feed** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| has | has a value |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Downloaded** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Played** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on |
|
||||||
|
| is false | is switched off *(default)* |
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### My reading
|
||||||
|
|
||||||
|
**Progress percent** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Last read** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**My rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| unrated | — |
|
||||||
|
|
||||||
|
**Has review** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Highlights** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Note text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Highlight text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Time spent (minutes)** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| in any | is in at least one of the collections you pick |
|
||||||
|
| in none | — |
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
**Published year** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published date** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Anything here that doesn't match what the app actually shows is a bug — this page is generated
|
||||||
|
from the app's own menus and a test fails if they ever disagree.*
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Filters — the complete guide
|
||||||
|
|
||||||
|
Filters answer one question: *of everything in my library, show me the ones that match.*
|
||||||
|
|
||||||
|
You'll meet the same filter system in three places:
|
||||||
|
|
||||||
|
| Place | What it's for |
|
||||||
|
| --- | --- |
|
||||||
|
| **The Filters drawer** (All Books / Podcasts pages) | A quick, temporary narrowing of what you see. Save it if you want to keep it. |
|
||||||
|
| **Smart collections** (a collection's **Rules** page) | A filter that *lives* — the collection's contents update automatically forever. |
|
||||||
|
| **Dashboard sections** | The same rules, feeding a shelf on your home screen. |
|
||||||
|
|
||||||
|
One system, one set of skills, three payoffs.
|
||||||
|
|
||||||
|
## Two worlds: Books and Episodes
|
||||||
|
|
||||||
|
Rules about **books** (authors, series, your reading progress) and rules about **podcast episodes**
|
||||||
|
(feeds, downloaded, played) are two different worlds. The field menus only show the fields that
|
||||||
|
make sense where you are — you'll never see a podcast field on the All Books page. The complete
|
||||||
|
list of every field is in [Every filter field](fields.md).
|
||||||
|
|
||||||
|
## Anatomy of a rule
|
||||||
|
|
||||||
|
Every rule is **a field + a question + your values**:
|
||||||
|
|
||||||
|
> **Author** *contains* `asc`
|
||||||
|
|
||||||
|
> **Feed** *in* `7th Son: Book One`
|
||||||
|
|
||||||
|
> **Page count** *at least* `400`
|
||||||
|
|
||||||
|
The field is *what* you're asking about. The question (contains, at least, is empty…) is *how*.
|
||||||
|
Your values are *what counts as a match*. The complete questions per field are in
|
||||||
|
[Every filter field](fields.md).
|
||||||
|
|
||||||
|
## Picking values
|
||||||
|
|
||||||
|
Two kinds of value boxes:
|
||||||
|
|
||||||
|
- **Dropdown boxes** (Feed, Library, Collection, Added by): start typing and real names appear,
|
||||||
|
with live counts of how much matches. Click to pick — each pick becomes a **chip** with a little
|
||||||
|
**×** to remove it. Pick as many as you like; *any one of them* matching is enough.
|
||||||
|
- **Typed boxes** (Author, Title, Page count…): just type. Several words means "contains these
|
||||||
|
words"; number boxes take numbers; date boxes take a number of days.
|
||||||
|
|
||||||
|
The dropdown's counts are **live under your other rules**: if you've already narrowed to
|
||||||
|
*Title contains Chapter*, the feed menu shows how many Chapter-titled episodes each of your feeds
|
||||||
|
has. Rules already picked disappear from their own menu (removing a chip brings them back).
|
||||||
|
|
||||||
|
## Groups: ALL and ANY
|
||||||
|
|
||||||
|
When you have several rules, the group header — **MATCH ALL** or **MATCH ANY** — decides how they
|
||||||
|
combine:
|
||||||
|
|
||||||
|
- **MATCH ALL** — *every* rule must match. (The default, and what you want almost always.)
|
||||||
|
- **MATCH ANY** — *at least one* rule matching is enough. This is how you ask for
|
||||||
|
"Lewis Carroll **or** George Orwell".
|
||||||
|
|
||||||
|
**Click the header to switch it.** It says what it does, and clicking flips it.
|
||||||
|
|
||||||
|
`+ group` adds a nested group with its own header (**GROUP ALL / GROUP ANY**), so bigger shapes
|
||||||
|
build naturally:
|
||||||
|
|
||||||
|
> Match ALL: Author *contains* `Carroll` **AND** GROUP ANY (Title contains `Alice` **OR** Title
|
||||||
|
> contains `Wonderland`)
|
||||||
|
|
||||||
|
For fine-tuning inside an ALL group, the small **AND / OR** chip between two rules mixes them —
|
||||||
|
click `AND` and it becomes `OR`, joining those neighbors together. (The header switch and these
|
||||||
|
chips are two different tools: the header sets the group's overall combine; a chip makes just two
|
||||||
|
neighboring rules special.)
|
||||||
|
|
||||||
|
## The live count
|
||||||
|
|
||||||
|
While you build, the counter at the bottom — *N books match* — runs your rules against the real
|
||||||
|
library. It's the exact number you'll get when you apply.
|
||||||
|
|
||||||
|
## Defaults worth knowing
|
||||||
|
|
||||||
|
- **Podcast listening order**: podcast pages and new podcast collections start at
|
||||||
|
**Feed order (reversed)** — book order, chapter 1 first. Change it per feed or collection from
|
||||||
|
the order dropdown; your choice sticks.
|
||||||
|
- **Hidden libraries** (family lens) never match — a filter can't accidentally surface a library
|
||||||
|
that's hidden from you.
|
||||||
|
- **Episodes that aren't downloaded** stay out of playback chains (they still appear in lists).
|
||||||
|
|
||||||
|
## Keeping a filter
|
||||||
|
|
||||||
|
- **Save** (drawer): name it; it appears in the **Load** menu from then on.
|
||||||
|
- **Apply to Collection** (rules page): the filter becomes the smart collection — live forever.
|
||||||
|
- Filtered views are **shareable**: the URL carries the filter, so a bookmark or sent link opens
|
||||||
|
the same view.
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Frontend Guide
|
|
||||||
|
|
||||||
**Coming Soon**
|
|
||||||
|
|
||||||
This guide will cover:
|
|
||||||
|
|
||||||
- Navigating the Bookhoard web interface
|
|
||||||
- Browsing and searching your library
|
|
||||||
- Managing collections
|
|
||||||
- Viewing reading progress
|
|
||||||
- Using notes and highlights
|
|
||||||
- Personalizing your experience
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_In the meantime, check out our [Device Setup Guides](devices/)_
|
|
||||||
@@ -1,58 +1,43 @@
|
|||||||
# Saving Custom Filters
|
# Browsing your library
|
||||||
|
|
||||||
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
|
Everything starts from the **All Books** page: every book you can see in the
|
||||||
|
current library, as a wall of covers. From there you narrow, sort, and open.
|
||||||
|
|
||||||
## Bookshelf Toolbar
|
## The All Books toolbar
|
||||||
|
|
||||||
The All Books page has a toolbar with:
|
- The **search box** finds titles, authors, and more as you type
|
||||||
|
- The **Sort** dropdown orders the wall: **Title (A-Z)**, **Title (Z-A)**,
|
||||||
|
**Author (A-Z)**, **Date Added**, or **Page Count**
|
||||||
|
- The **Filters** button opens the filter drawer — authors, tags, series,
|
||||||
|
progress, and every other field (the full how-to lives in
|
||||||
|
[Filters — the complete guide](filters/index.md); this page sticks to
|
||||||
|
browsing)
|
||||||
|
- **Save**, **Load**, and **Clear** manage your saved filters: capture the
|
||||||
|
current drawer state under a name, re-apply it any time, and delete ones
|
||||||
|
you no longer want. Saved filters are private to your account.
|
||||||
|
|
||||||
- A **search input** for quick text searches
|
Saved filters pair naturally with [smart collections](collections.md): a
|
||||||
- A **sort** dropdown (title, author, date added, page count)
|
filter answers "what matches right now?", a smart collection keeps matching
|
||||||
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
|
forever.
|
||||||
- **Save**, **Load**, and **Clear** buttons for filter presets
|
|
||||||
|
|
||||||
## How to Save a Filter
|
## Opening a book
|
||||||
|
|
||||||
1. Navigate to the **All Books** page
|
Click any cover to open the book's detail page — description, files, your
|
||||||
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
|
progress, actions to read or listen, and the places to edit metadata or fix
|
||||||
3. Click the **Save** button in the toolbar
|
what a scan guessed wrong. The detail page is the hub: series links,
|
||||||
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
reviews, bookmarks, and the edit modal all live there.
|
||||||
5. Click **Save**
|
|
||||||
|
|
||||||
## Loading Saved Filters
|
## Series
|
||||||
|
|
||||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
A book's detail page links to its **series page** — every book in that
|
||||||
2. Click a filter's name to apply it
|
series, in order, for the same library. Series are one of the fields the
|
||||||
3. The filter values are applied instantly, without a page reload
|
filter drawer can match on too.
|
||||||
|
|
||||||
## Managing Saved Filters
|
## Podcasts are separate
|
||||||
|
|
||||||
**Delete a filter:**
|
Podcast feeds and episodes live on the **Podcasts** page, not the book wall —
|
||||||
|
see [Podcasts](podcasts.md).
|
||||||
|
|
||||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
*Finding your way back:* **All Books** is the wall-of-covers page with the
|
||||||
2. Click the trash icon next to the filter you want to remove
|
**Filters** button; the same toolbar pattern is what you'll see on the
|
||||||
3. Confirm deletion
|
Podcasts page.
|
||||||
|
|
||||||
**Filter Privacy:**
|
|
||||||
|
|
||||||
Saved filters are **private to your account**. Other users cannot see or modify your filters.
|
|
||||||
|
|
||||||
## Common Use Cases
|
|
||||||
|
|
||||||
**Reading by Genre:**
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Podcasts
|
||||||
|
|
||||||
|
Bookhoard is not just books: it also runs your podcast subscriptions. You
|
||||||
|
subscribe to feeds, episodes download for offline listening, and you can
|
||||||
|
play a run of episodes back-to-back — a feed, or a hand-picked sequence.
|
||||||
|
|
||||||
|
## The Podcasts page
|
||||||
|
|
||||||
|
Open **Podcasts** from the sidebar. The page is your subscriptions:
|
||||||
|
|
||||||
|
- **Continue** — the episode you were in the middle of, ready to resume
|
||||||
|
- **All Feeds** — every feed you subscribe to
|
||||||
|
- **Search** narrows your feeds and episodes
|
||||||
|
- Each feed's page holds its episodes, with **Download All** for grabbing
|
||||||
|
everything at once
|
||||||
|
|
||||||
|
Subscriptions live in your **Podcasts library** (feeds manage it themselves —
|
||||||
|
you won't see it in the regular library switcher, and it never mixes into
|
||||||
|
the book wall on **All Books**).
|
||||||
|
|
||||||
|
## Subscribing to a feed
|
||||||
|
|
||||||
|
1. Click **Add Feed** on the Podcasts page
|
||||||
|
2. In the **Add Podcast Feed** panel, paste the show's **RSS feed URL**
|
||||||
|
3. Choose the **Library** the podcast belongs to
|
||||||
|
4. Optionally: check **Download all episodes now** to grab the back catalog
|
||||||
|
immediately, and pick the **Download order** for new episodes — **Feed
|
||||||
|
order**, **Feed order (reversed)**, **Oldest first (pub date)**, or
|
||||||
|
**Newest first (pub date)**
|
||||||
|
5. Click **Add & Subscribe**
|
||||||
|
|
||||||
|
From then on Bookhoard watches the feed: new episodes appear as the show
|
||||||
|
publishes them, downloaded according to the order you picked.
|
||||||
|
|
||||||
|
## Playing episodes
|
||||||
|
|
||||||
|
Click an episode to open the audio player. The player keeps your place,
|
||||||
|
supports bookmarks, a chapter list where the show provides chapters, and
|
||||||
|
playback speed — the same speed and resume-rewind defaults you set on your
|
||||||
|
[profile](profile-guide.md) apply here.
|
||||||
|
|
||||||
|
To play many episodes in a row, use a collection: episode collections have a
|
||||||
|
**Play as Sequence** button and their own listening order — see
|
||||||
|
[Collections](collections.md).
|
||||||
|
|
||||||
|
*Finding your way back:* **Podcasts** is the sidebar page whose header reads
|
||||||
|
"Your subscriptions" — the only page with the **Add Feed** button.
|
||||||
+37
-87
@@ -1,99 +1,49 @@
|
|||||||
# Profile Management Guide
|
# Your profile
|
||||||
|
|
||||||
## Updating Your Profile
|
Your profile page holds everything that is about *you* rather than your
|
||||||
|
books: account details, playback preferences, your password, and — if you
|
||||||
|
ever need it — account deletion.
|
||||||
|
|
||||||
Your profile contains your account information and preferences.
|
Open it from the sidebar: click your **username** at the bottom, then
|
||||||
|
**Profile**.
|
||||||
|
|
||||||
### How to Update
|
## Account information
|
||||||
|
|
||||||
1. Click on your **username** at the bottom of the sidebar to expand the account menu
|
| Field | What it does |
|
||||||
2. Select **Profile**
|
| --- | --- |
|
||||||
3. Edit any fields in the "Account Information" section
|
| **Username** | Your login name — must stay unique |
|
||||||
4. Click **Save Changes**
|
| **Email Address** | Must stay unique |
|
||||||
5. Changes take effect immediately
|
| **First Name / Last Name** | Optional display names |
|
||||||
|
| **Theme** | A quick theme picker (seven favorites — see below for the full set) |
|
||||||
|
| **Default Playback Speed** | The speed audiobook playback starts at |
|
||||||
|
| **Resume Rewind** | Jumps back a few seconds when you resume playback (**Off (exact resume)**, 5, or 10 seconds) |
|
||||||
|
| **Keep Screen Awake** | Stops the screen sleeping during playback |
|
||||||
|
| **Timezone** | All times the server shows you (activity, stats, history) are rendered in this zone |
|
||||||
|
|
||||||
### Fields You Can Update
|
Changes save from the form's save button and apply immediately.
|
||||||
|
|
||||||
- **Username** - Your login name (must be unique)
|
About the two theme doors: the profile's **Theme** select offers seven
|
||||||
- **Email** - Your email address (must be unique)
|
quick picks. The sidebar's **Appearance** panel offers the full collection —
|
||||||
- **First Name** - Optional display name
|
[Themes and Wood Paneling](themes.md) covers it, including the bookshelf
|
||||||
- **Last Name** - Optional display name
|
wood textures, which are per-browser and not part of your account.
|
||||||
- **Theme** - Your preferred color scheme
|
|
||||||
|
|
||||||
## Changing Your Password
|
## Changing your password
|
||||||
|
|
||||||
Regular password changes are recommended for account security.
|
On the same page, the **Change Password** section asks for your **Current
|
||||||
|
Password**, a **New Password**, and **Confirm New Password**. The
|
||||||
|
requirements are checked as you type; click **Update Password** to save.
|
||||||
|
|
||||||
### How to Change
|
## Deleting your account
|
||||||
|
|
||||||
1. Go to **Profile** page
|
The **Danger Zone** at the bottom holds **Remove My Account**. Deletion
|
||||||
2. Scroll to "Change Password" section
|
removes your account and everything personal to it — profile, reading
|
||||||
3. Enter your **current password**
|
progress and history, device registrations, your collections — and keeps the
|
||||||
4. Enter your **new password**
|
libraries themselves, since books belong to the library, not to you. It
|
||||||
5. **Confirm** your new password
|
cannot be undone.
|
||||||
6. Click **Update Password**
|
|
||||||
|
|
||||||
### Password Requirements
|
The last administrator cannot delete their own account — one admin must
|
||||||
|
always remain.
|
||||||
|
|
||||||
- Minimum 8 characters
|
Administrators can also edit other users' profiles (including resetting a
|
||||||
- Must contain uppercase, lowercase, number, and special character
|
password without the current one) from the admin area; that is covered with
|
||||||
- Must match confirmation field
|
the rest of the admin docs.
|
||||||
- Current password must be correct
|
|
||||||
|
|
||||||
## Deleting Your Account
|
|
||||||
|
|
||||||
**⚠️ WARNING:** This action cannot be undone.
|
|
||||||
|
|
||||||
### What Gets Deleted
|
|
||||||
|
|
||||||
When you delete your account:
|
|
||||||
|
|
||||||
- Your profile information
|
|
||||||
- Reading progress and history
|
|
||||||
- Device connections
|
|
||||||
- Collection preferences
|
|
||||||
- All personal data
|
|
||||||
|
|
||||||
### What Stays
|
|
||||||
|
|
||||||
- Library books (media belongs to the library, not you)
|
|
||||||
- System settings
|
|
||||||
- Other users' accounts
|
|
||||||
|
|
||||||
### How to Delete
|
|
||||||
|
|
||||||
1. Go to **Profile** page
|
|
||||||
2. Scroll to "Danger Zone" (bottom of page)
|
|
||||||
3. Click **Remove My Account**
|
|
||||||
4. Confirm the deletion prompt
|
|
||||||
|
|
||||||
**Note:** If you're the last admin, you cannot delete your account for security reasons.
|
|
||||||
|
|
||||||
## Theme Options
|
|
||||||
|
|
||||||
Personalize your reading experience with different color themes.
|
|
||||||
|
|
||||||
### Quick Theme Switch
|
|
||||||
|
|
||||||
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
|
|
||||||
- **Dracula** - Purple/pink tones
|
|
||||||
- **Nord** - Arctic, bluish-gray
|
|
||||||
- **Solarized Dark** - Warm, precise contrast
|
|
||||||
- **Monokai** - Classic vibrant colors
|
|
||||||
- **One Dark Pro** - Atom editor inspired
|
|
||||||
- **Material Dark** - Google Material Design
|
|
||||||
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
|
|
||||||
|
|
||||||
## For Admin Users
|
|
||||||
|
|
||||||
If you're an administrator, you can also manage other users' profiles from the admin panel.
|
|
||||||
|
|
||||||
See [Admin User Management](admin-guide.md) for details.
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Bookhoard's built-in web reader works in any modern browser — desktop or
|
Bookhoard's built-in web reader works in any modern browser — desktop or
|
||||||
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
||||||
with the Android app and KOReader.
|
with the Android app and KOReader. Audiobooks get their own player, covered
|
||||||
|
at the end of this guide.
|
||||||
|
|
||||||
## Opening a Book
|
## Opening a Book
|
||||||
|
|
||||||
@@ -79,3 +80,25 @@ to jump to it.
|
|||||||
Position, highlights, bookmarks, and notes sync automatically through the
|
Position, highlights, bookmarks, and notes sync automatically through the
|
||||||
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
||||||
like — the reader resumes exactly where you left off on any device.
|
like — the reader resumes exactly where you left off on any device.
|
||||||
|
|
||||||
|
## Listening to audiobooks and podcast episodes
|
||||||
|
|
||||||
|
Books with audio files, and podcast episodes, open in the audio player
|
||||||
|
instead of the page reader. It is one player for both:
|
||||||
|
|
||||||
|
- The **seek bar** is your position; drag it, or use the **previous /
|
||||||
|
next episode** controls when you are playing a sequence
|
||||||
|
- **Bookmarks** opens a panel of your markers for this title — tap one to
|
||||||
|
jump back
|
||||||
|
- **Chapters** opens the chapter list (shows "currently chapter N of M");
|
||||||
|
audiobooks with embedded chapters and podcast shows with chapter marks
|
||||||
|
both land here
|
||||||
|
- **Player settings** holds the per-player preferences: **Autoplay on
|
||||||
|
open**, **Default playback speed**, and **Resume rewind** (a few seconds
|
||||||
|
back when you resume — the defaults come from your
|
||||||
|
[profile](profile-guide.md) and this panel overrides them per player)
|
||||||
|
- The **Shortcuts & interactions** panel (the **?** control) lists every
|
||||||
|
keyboard shortcut
|
||||||
|
|
||||||
|
Progress, bookmarks, and your speed carry across devices through the same
|
||||||
|
sync as everything else.
|
||||||
@@ -35,3 +35,12 @@ You can combine tags with other filters:
|
|||||||
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
||||||
- Best matching tags appear first in results
|
- Best matching tags appear first in results
|
||||||
- Autocomplete shows existing tags as you type with relevance scores
|
- Autocomplete shows existing tags as you type with relevance scores
|
||||||
|
|
||||||
|
|
||||||
|
## The complete filter guide
|
||||||
|
|
||||||
|
The filters described above are one system that also powers smart collections and dashboard
|
||||||
|
sections — groups with ALL/ANY, saved filters, and every field listed. The full manual lives in
|
||||||
|
[this folder](filters/index.md): the [guide](filters/index.md), the
|
||||||
|
[cookbook](filters/cookbook.md) of ready-made recipes, and the generated
|
||||||
|
[field reference](filters/fields.md).
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Settings Guide
|
|
||||||
|
|
||||||
**Coming Soon**
|
|
||||||
|
|
||||||
This guide will cover:
|
|
||||||
|
|
||||||
- Account settings
|
|
||||||
- Display preferences (themes, fonts)
|
|
||||||
- Sync configuration
|
|
||||||
- Device management
|
|
||||||
- Privacy options
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_In the meantime, check out our [Device Setup Guides](devices/)_
|
|
||||||
+101
-555
@@ -1,591 +1,137 @@
|
|||||||
# Bookhoard Universal Sync - User Guide
|
# Universal Sync — how your devices stay in step
|
||||||
|
|
||||||
## Table of Contents
|
Sync is the promise that makes Bookhoard more than a web page: read on
|
||||||
|
whatever is in your hands, and every other device catches up. One server
|
||||||
|
holds the truth for each book — your position, bookmarks, highlights, and
|
||||||
|
notes — and every registered device pushes and pulls against it.
|
||||||
|
|
||||||
1. [What is Universal Sync?](#what-is-universal-sync)
|
## Supported devices today
|
||||||
2. [Supported Devices](#supported-devices)
|
|
||||||
3. [Getting Started](#getting-started)
|
|
||||||
4. [Device Setup](#device-setup)
|
|
||||||
5. [Using Sync Features](#using-sync-features)
|
|
||||||
6. [Book Matching](#book-matching)
|
|
||||||
7. [Managing Conflicts](#managing-conflicts)
|
|
||||||
8. [Best Practices](#best-practices)
|
|
||||||
|
|
||||||
---
|
| Platform | Status | How it syncs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Web browser** | ✅ Fully supported | Live, as you read — the reader saves position and annotations continuously |
|
||||||
|
| **Android app** | ✅ Fully supported | The native client: progress, highlights, bookmarks, notes; reads offline and queues changes until you are back online |
|
||||||
|
| **KOReader** (Kindle, Kobo, PocketBook) | ✅ Fully supported | The Bookhoard KOReader plugin, over Wi-Fi |
|
||||||
|
| **Kobo, stock firmware** | 🚧 Server support complete | The native Kobo protocol is implemented and documented server-side; device-side enablement is the remaining piece — [Kobo Setup](devices/kobo-setup.md) has the details |
|
||||||
|
|
||||||
## What is Universal Sync?
|
## Getting a device into the circle
|
||||||
|
|
||||||
**Universal Sync** is Bookhoard's cross-platform synchronization system that keeps your reading progress, highlights, and notes in sync across all your devices - automatically and in real-time.
|
1. Connect the device to your server (each guide above walks its own path)
|
||||||
|
2. Pair it on the **Devices** page — the Android app registers itself on
|
||||||
|
first login; KOReader shows a 6-digit pairing code that you type into
|
||||||
|
**Pair a Device** on the page
|
||||||
|
3. Done. Sync starts as soon as a device is paired
|
||||||
|
|
||||||
### Key Features
|
Only the person holding the device can read its pairing code, so a device
|
||||||
|
always lands in the account of whoever registered it — even on a family
|
||||||
|
server where more than one person is logged in.
|
||||||
|
|
||||||
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
|
The **Devices** page is mission control: see every registered device and
|
||||||
|
when it was last seen, remove a device (revoking its access instantly), and
|
||||||
|
work through **unlinked books** — device files that could not be matched to
|
||||||
|
a library book yet.
|
||||||
|
|
||||||
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap
|
## Book matching
|
||||||
|
|
||||||
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
|
When a device reports a book, Bookhoard tries to match it to your library
|
||||||
|
by fingerprint, strongest first:
|
||||||
|
|
||||||
⚡ **Real-Time Updates** - See your progress update across all devices instantly
|
1. **Bookhoard UUID** — 100% confidence
|
||||||
|
2. **OPF UUID** — 95%
|
||||||
|
3. **SHA-256 hash** — 90%
|
||||||
|
4. **OPF identifier** — 85%
|
||||||
|
5. **ISBN / ASIN** — 80%
|
||||||
|
6. **Title + Author + file size** — 50%
|
||||||
|
7. **Title only** — 30% (last resort, only tried when nothing else matched)
|
||||||
|
|
||||||
🔒 **Secure** - No passwords on devices, web-based authentication only
|
Books that match automatically never bother you. The ones that can't
|
||||||
|
(side-loaded files, converted formats like EPUB → KEPUB, mismatched
|
||||||
|
metadata) land in the device's **unlinked books** list, where you can:
|
||||||
|
|
||||||
📴 **Offline Support** - Queue changes when offline, sync when reconnected
|
- **Auto-link** everything above a confidence threshold
|
||||||
|
- Review **suggestions** first, then link
|
||||||
|
- **Link by hand** — pick the right library book yourself
|
||||||
|
|
||||||
---
|
## What syncs
|
||||||
|
|
||||||
## Supported Devices
|
**Reading progress** — page, percentage, chapter position, EPUB CFI, last
|
||||||
|
read time. EPUBs sync by percentage and CFI, PDFs and comics by page, so
|
||||||
|
the same book lines up on devices with different page counts.
|
||||||
|
|
||||||
### Currently Supported ✅
|
**Highlights and notes** — the highlighted text, your note, its color, and
|
||||||
|
where it sits. Locations are stored in several forms at once (page, EPUB
|
||||||
|
CFI, percentage, character offset), which is why a highlight made on your
|
||||||
|
phone lands in the right place on an e-reader with different pagination.
|
||||||
|
|
||||||
| Platform | Status | Sync Method | Notes |
|
**Bookmarks** — location, title, and when you made them.
|
||||||
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
|
|
||||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
|
||||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
|
|
||||||
|
|
||||||
### Coming Soon 🚧
|
Everything above is per-user and private: your devices see your data
|
||||||
|
because you paired them, other users see theirs, and revoking a device
|
||||||
|
ends its access on the spot.
|
||||||
|
|
||||||
| Platform | Status |
|
## Conflicts
|
||||||
| --------------------- | ------------------------------------------------------------- |
|
|
||||||
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
|
|
||||||
| **Mobile Apps** | Android/iOS apps coming later |
|
|
||||||
|
|
||||||
### On the Roadmap 🔭
|
If the same book is read on two devices close together, Bookhoard does not
|
||||||
|
silently pick a winner — it records a **sync conflict** and shows it to
|
||||||
|
you:
|
||||||
|
|
||||||
| Platform | Status |
|
- The book's detail page grows a **Sync Progress** button (with a
|
||||||
| --------------------- | ---------------------------------------- |
|
"Progress conflict detected" notice)
|
||||||
| **Kindle Devices** | Under consideration (no date yet) |
|
- The **Conflicts** page (`/conflicts`) lists everything unresolved
|
||||||
| **Remarkable Tablet** | Under consideration (no date yet) |
|
|
||||||
|
|
||||||
---
|
Resolving means choosing a strategy: keep one side's progress, keep the
|
||||||
|
server's current value, or let the **furthest progress win**. Until you
|
||||||
|
resolve, the conflict stays listed — nothing is lost, it just waits for
|
||||||
|
your call.
|
||||||
|
|
||||||
## Getting Started
|
## Offline reading
|
||||||
|
|
||||||
### Prerequisites
|
Devices keep working without the server. The Android app reads fully
|
||||||
|
offline and queues your changes, draining the queue when you reconnect.
|
||||||
|
E-readers push their progress on the next connection. The server holds a
|
||||||
|
per-device queue for inbound work and retries failures automatically; if
|
||||||
|
items pile up (a device offline for weeks), an admin can retry or clear a
|
||||||
|
device's queue from the server side.
|
||||||
|
|
||||||
1. **Bookhoard Server** - Self-hosted instance running and accessible
|
## Reading history
|
||||||
2. **Network Connection** - Devices must be able to reach your Bookhoard server
|
|
||||||
3. **User Account** - Created and logged in to Bookhoard web interface
|
|
||||||
|
|
||||||
### Quick Start
|
Every reading session is logged — when, how long, on which device — and
|
||||||
|
feeds your [dashboard](dashboard.md) and stats. Your history is visible on
|
||||||
1. **Set up your first device** - See [Device Setup](#device-setup) below
|
the **Progress** page and on each book's detail page, and it is yours
|
||||||
2. **Start reading** - Open any book in the web interface or on your device
|
alone.
|
||||||
3. **Turn pages** - Progress syncs automatically
|
|
||||||
4. **Switch devices** - Pick up any other device - your progress is there!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Device Setup
|
|
||||||
|
|
||||||
For detailed device configuration instructions, see the appropriate setup guide:
|
|
||||||
|
|
||||||
- **[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** (KOReader):
|
|
||||||
|
|
||||||
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**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Devices page (sidebar navigation)
|
|
||||||
```
|
|
||||||
|
|
||||||
You can:
|
|
||||||
|
|
||||||
- View all your registered devices
|
|
||||||
- See last sync time and status
|
|
||||||
- Disable or remove devices
|
|
||||||
- View unlinked books that need matching
|
|
||||||
- Manage file aliases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Book Matching
|
|
||||||
|
|
||||||
### What is Book Matching?
|
|
||||||
|
|
||||||
When devices sync books, Bookhoard tries to automatically match them using:
|
|
||||||
|
|
||||||
- **SHA-256 hash** (most reliable) - Content-based fingerprint
|
|
||||||
- **ISBN** - Standard book identifier
|
|
||||||
- **UUID** - Unique identifier from EPUB metadata
|
|
||||||
- **Title + Author** (fallback) - Last resort matching
|
|
||||||
|
|
||||||
### Unlinked Books
|
|
||||||
|
|
||||||
Sometimes a book on your device can't be automatically matched to your library. This happens when:
|
|
||||||
|
|
||||||
- The book was side-loaded (not downloaded via Bookhoard)
|
|
||||||
- The file format was converted (EPUB → KEPUB)
|
|
||||||
- The metadata doesn't match exactly
|
|
||||||
|
|
||||||
### Viewing Unlinked Books
|
|
||||||
|
|
||||||
```
|
|
||||||
Devices page → select device → unlinked books
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resolving Unlinked Books
|
|
||||||
|
|
||||||
1. **Auto-Link** - Automatically link books with 80%+ confidence
|
|
||||||
2. **Get Suggestions** - Review potential matches before linking
|
|
||||||
3. **Manual Link** - Choose the correct match yourself
|
|
||||||
|
|
||||||
### Book Matching Priority
|
|
||||||
|
|
||||||
1. **Bookhoard UUID** - 100% confidence
|
|
||||||
2. **OPF UUID** - 95% confidence
|
|
||||||
3. **SHA-256 hash** - 90% confidence
|
|
||||||
4. **OPF identifier** - 85% confidence
|
|
||||||
5. **ISBN/ASIN** - 80% confidence
|
|
||||||
6. **Title + Author + File Size** - 50% confidence
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Managing Conflicts
|
|
||||||
|
|
||||||
### Reading Progress
|
|
||||||
|
|
||||||
**What Syncs**:
|
|
||||||
|
|
||||||
- Current page number
|
|
||||||
- Reading percentage
|
|
||||||
- Chapter progress
|
|
||||||
- EPUB CFI (for EPUB files)
|
|
||||||
- Last read timestamp
|
|
||||||
- Reading position (viewport, zoom, scroll)
|
|
||||||
|
|
||||||
**How It Works**:
|
|
||||||
|
|
||||||
```
|
|
||||||
You turn page → Device sends progress → Server updates database
|
|
||||||
↓
|
|
||||||
Broadcasts to all your devices
|
|
||||||
↓
|
|
||||||
Other devices update their display
|
|
||||||
```
|
|
||||||
|
|
||||||
**Supported Progress Types**:
|
|
||||||
|
|
||||||
- **EPUB/MOBI**: Percentage + EPUB CFI + Chapter
|
|
||||||
- **PDF/DJVU**: Page number + Viewport position
|
|
||||||
- **CBZ/CBR**: Page number + Panel coordinates
|
|
||||||
|
|
||||||
### Highlights & Notes
|
|
||||||
|
|
||||||
**What Syncs**:
|
|
||||||
|
|
||||||
- Highlighted text
|
|
||||||
- Notes and annotations
|
|
||||||
- Bookmark locations
|
|
||||||
- Colors and formatting
|
|
||||||
- Chapter/paragraph references
|
|
||||||
|
|
||||||
**Universal Location References**:
|
|
||||||
All highlights are stored with multiple location types:
|
|
||||||
|
|
||||||
- Page:offset (traditional)
|
|
||||||
- EPUB CFI (EPUB files)
|
|
||||||
- Percentage (0-100%)
|
|
||||||
- Character offset (exact position)
|
|
||||||
- Chapter references
|
|
||||||
|
|
||||||
This ensures your highlights work across all devices, even with different page counts!
|
|
||||||
|
|
||||||
### Bookmarks
|
|
||||||
|
|
||||||
**What Syncs**:
|
|
||||||
|
|
||||||
- Bookmark locations
|
|
||||||
- Bookmark titles
|
|
||||||
- Date created
|
|
||||||
- Reading position context
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sync Modes
|
|
||||||
|
|
||||||
### Immediate Mode (Default)
|
|
||||||
|
|
||||||
**Best For**: Normal reading, page turns
|
|
||||||
|
|
||||||
**Behavior**:
|
|
||||||
|
|
||||||
- Syncs every page turn
|
|
||||||
- Real-time updates
|
|
||||||
- Low latency
|
|
||||||
- Higher bandwidth usage
|
|
||||||
|
|
||||||
**Recommended Settings**:
|
|
||||||
|
|
||||||
- Auto-sync: ON
|
|
||||||
- Sync frequency: Every page turn
|
|
||||||
|
|
||||||
### Checkpoint Mode
|
|
||||||
|
|
||||||
**Best For**: Slow connections, battery saving
|
|
||||||
|
|
||||||
**Behavior**:
|
|
||||||
|
|
||||||
- Batches changes
|
|
||||||
- Syncs every 5 minutes or when connection allows
|
|
||||||
- Lower bandwidth
|
|
||||||
- Better for offline reading
|
|
||||||
|
|
||||||
**Recommended Settings**:
|
|
||||||
|
|
||||||
- Auto-sync: ON
|
|
||||||
- Sync frequency: Checkpoint mode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Offline Support
|
|
||||||
|
|
||||||
### How Offline Sync Works
|
|
||||||
|
|
||||||
1. **Offline Reading**:
|
|
||||||
- Read normally without connection
|
|
||||||
- All progress tracked locally
|
|
||||||
|
|
||||||
2. **Queue Changes**:
|
|
||||||
- Progress changes queued automatically
|
|
||||||
- Annotations saved locally
|
|
||||||
- Up to 100 items in offline queue
|
|
||||||
|
|
||||||
3. **Reconnection**:
|
|
||||||
- Connect to Wi-Fi
|
|
||||||
- Queue processes automatically
|
|
||||||
- Priority items first (book completion, notes)
|
|
||||||
- All devices updated
|
|
||||||
|
|
||||||
### Offline Indicators
|
|
||||||
|
|
||||||
**In Web Interface**:
|
|
||||||
|
|
||||||
- Yellow status icon: Device offline
|
|
||||||
- Last seen timestamp
|
|
||||||
- "Pending sync" badge on books
|
|
||||||
|
|
||||||
**On Devices**:
|
|
||||||
|
|
||||||
- Sync icon: Gray = offline
|
|
||||||
- Sync icon: Blue = syncing
|
|
||||||
- Sync icon: Green = synced
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Sync Not Working
|
**Progress not updating**
|
||||||
|
Check the device is still on the **Devices** page and was paired; check
|
||||||
|
the server URL in the device's settings; confirm the device can reach the
|
||||||
|
server (open the server URL in the device's browser as a test).
|
||||||
|
|
||||||
**Symptoms**: Progress not updating across devices
|
**"Device not found"**
|
||||||
|
The device was removed or its token revoked — register it again and
|
||||||
|
enter the fresh pairing code.
|
||||||
|
|
||||||
**Solutions**:
|
**A conflict keeps coming back**
|
||||||
|
Two devices are genuinely fighting over the same book. Decide which device
|
||||||
|
is the reader for that book, resolve with a strategy above, and avoid
|
||||||
|
simultaneous reading of the same title.
|
||||||
|
|
||||||
1. Check device is online: Devices page (sidebar navigation)
|
**High battery or data use**
|
||||||
2. Verify sync is enabled for the device
|
That is the cost of continuous sync on a chatty device; e-reader plugins
|
||||||
3. Check sync URL is correct
|
let you space out their sync checks from the plugin's own settings.
|
||||||
4. Ensure device has network connection
|
|
||||||
5. Try manual sync: Open book → Menu → Sync Now
|
|
||||||
|
|
||||||
### "Device Not Found" Error
|
## Device limits
|
||||||
|
|
||||||
**Cause**: Device not registered or authorization revoked
|
Each account has a device limit — **10 by default**, and administrators can
|
||||||
|
raise or lower it per user. Removing a device you no longer use frees a
|
||||||
**Solutions**:
|
slot.
|
||||||
|
|
||||||
1. Re-register the device
|
|
||||||
2. Check device hasn't been removed
|
|
||||||
3. Verify correct device type selected
|
|
||||||
|
|
||||||
### "Rate Limit Exceeded" Error
|
|
||||||
|
|
||||||
**Cause**: Too many sync requests
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Wait a few seconds
|
|
||||||
2. Switch to checkpoint mode
|
|
||||||
3. Contact admin to increase limits
|
|
||||||
|
|
||||||
### Conflicts Detected
|
|
||||||
|
|
||||||
**Symptoms**: "Sync conflict" notification
|
|
||||||
|
|
||||||
**Cause**: Same book being read on multiple devices simultaneously
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
### High Battery Usage
|
|
||||||
|
|
||||||
**Cause**: Immediate sync mode with frequent page turns
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Switch to checkpoint mode
|
|
||||||
2. Increase sync interval
|
|
||||||
3. Sync less frequently
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Best Practices
|
More detail on any device lives in its guide:
|
||||||
|
[Android](devices/android-app.md) ·
|
||||||
### For Optimal Performance
|
[KOReader](devices/koreader-setup.md) ·
|
||||||
|
[Kobo](devices/kobo-setup.md) ·
|
||||||
✅ **DO**:
|
[Calibre import](calibre-integration.md) ·
|
||||||
|
and the [filter rules](filters/index.md) that power matching-adjacent
|
||||||
- Use checkpoint mode when on slow connections
|
shelves like [smart collections](collections.md).
|
||||||
- Keep device firmware updated
|
|
||||||
- Use Wi-Fi when available
|
|
||||||
- Approve only devices you own
|
|
||||||
- Regularly check conflict resolution
|
|
||||||
|
|
||||||
❌ **DON'T**:
|
|
||||||
|
|
||||||
- Read same book on multiple devices simultaneously
|
|
||||||
- Ignore conflict notifications
|
|
||||||
- Register public/shared devices
|
|
||||||
- Exceed device limits (max 10 per user)
|
|
||||||
|
|
||||||
### Organizing Your Library
|
|
||||||
|
|
||||||
**For Best Sync Experience**:
|
|
||||||
|
|
||||||
- Use consistent metadata (titles, authors)
|
|
||||||
- Avoid duplicate books in library
|
|
||||||
- Match files by ISBN when possible
|
|
||||||
- Use clean file structure
|
|
||||||
|
|
||||||
### Managing Multiple Devices
|
|
||||||
|
|
||||||
**Recommended Setup**:
|
|
||||||
|
|
||||||
- **Primary Device**: KOReader on e-reader
|
|
||||||
- **Secondary Device**: Web browser (work/home)
|
|
||||||
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
|
|
||||||
|
|
||||||
**Sync Strategy**:
|
|
||||||
|
|
||||||
1. Read mainly on primary device
|
|
||||||
2. Check progress on web/secondary devices
|
|
||||||
3. Let auto-sync handle updates
|
|
||||||
4. Resolve conflicts promptly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### Conflict Resolution
|
|
||||||
|
|
||||||
**Automatic Resolution**:
|
|
||||||
|
|
||||||
- Most recent progress wins
|
|
||||||
- Timestamp-based comparison
|
|
||||||
- 5-minute window for conflict detection
|
|
||||||
|
|
||||||
**Manual Resolution**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Book detail → Sync Progress → choose winner
|
|
||||||
```
|
|
||||||
|
|
||||||
**Options**:
|
|
||||||
|
|
||||||
- **Keep Device A**: Use this device's progress
|
|
||||||
- **Keep Device B**: Use other device's progress
|
|
||||||
- **Merge**: Keep furthest progress (combination)
|
|
||||||
- **Auto-Resolve Future**: Always prefer this device
|
|
||||||
|
|
||||||
### Sync Queue Management
|
|
||||||
|
|
||||||
**View Queue Status**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Devices page → sync queue section
|
|
||||||
```
|
|
||||||
|
|
||||||
**Queue Stats**:
|
|
||||||
|
|
||||||
- Pending: Waiting to sync
|
|
||||||
- Processing: Currently syncing
|
|
||||||
- Failed: Retry scheduled
|
|
||||||
- Completed: Successfully synced
|
|
||||||
|
|
||||||
**Manual Actions**:
|
|
||||||
|
|
||||||
- **Retry All**: Retry all failed items
|
|
||||||
- **Clear Queue**: Remove all pending items
|
|
||||||
- **Priority Sync**: Sync specific book immediately
|
|
||||||
|
|
||||||
### Reading History
|
|
||||||
|
|
||||||
**Automatic Tracking**:
|
|
||||||
|
|
||||||
- Every sync session logged
|
|
||||||
- Time spent reading calculated
|
|
||||||
- Pages read tracked
|
|
||||||
- Device used recorded
|
|
||||||
|
|
||||||
**View History**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Progress page (sidebar navigation), or the book's detail page
|
|
||||||
```
|
|
||||||
|
|
||||||
**Privacy**:
|
|
||||||
|
|
||||||
- Only you can see your history
|
|
||||||
- History kept for 365 days
|
|
||||||
- Exportable for backup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security & Privacy
|
|
||||||
|
|
||||||
### Device Authentication
|
|
||||||
|
|
||||||
**Secure by Design**:
|
|
||||||
|
|
||||||
- ✅ No passwords stored on devices
|
|
||||||
- ✅ Web-based approval required
|
|
||||||
- ✅ Unique tokens per device
|
|
||||||
- ✅ Revocable at any time
|
|
||||||
- ✅ Token encryption in transit
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
|
|
||||||
**What We Store**:
|
|
||||||
|
|
||||||
- Reading progress (page, percentage)
|
|
||||||
- Highlights and notes
|
|
||||||
- Device identifiers
|
|
||||||
- Sync timestamps
|
|
||||||
|
|
||||||
**What We DON'T Store**:
|
|
||||||
|
|
||||||
- Passwords on devices
|
|
||||||
- Reading content (your books)
|
|
||||||
- Unencrypted personal data
|
|
||||||
- Location data (GPS)
|
|
||||||
|
|
||||||
### Access Control
|
|
||||||
|
|
||||||
**Your Data**:
|
|
||||||
|
|
||||||
- Only you can see your progress
|
|
||||||
- Admins cannot read your annotations
|
|
||||||
- Shared only with devices you approve
|
|
||||||
|
|
||||||
**Device Access**:
|
|
||||||
|
|
||||||
- Each device sees only your libraries
|
|
||||||
- Devices cannot access other users
|
|
||||||
- Revoking removes all access
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
### For Faster Sync
|
|
||||||
|
|
||||||
1. **Use 5GHz Wi-Fi** - Faster than 2.4GHz
|
|
||||||
2. **Keep server nearby** - Low latency = better sync
|
|
||||||
3. **Regular updates** - Keeps firmware current
|
|
||||||
4. **Checkpoint mode** - For batch processing
|
|
||||||
|
|
||||||
### For Better Battery Life
|
|
||||||
|
|
||||||
1. **Checkpoint mode** - Fewer sync requests
|
|
||||||
2. **Increase sync interval** - Fewer updates
|
|
||||||
3. **Close when not reading** - Reduces background activity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
### General Questions
|
|
||||||
|
|
||||||
**Q: Does sync work with all book formats?**
|
|
||||||
A: Yes! EPUB, PDF, MOBI, CBZ, CBR and more.
|
|
||||||
|
|
||||||
**Q: Can multiple users share a device?**
|
|
||||||
A: No, devices are tied to individual accounts for security.
|
|
||||||
|
|
||||||
**Q: What happens if I delete a book?**
|
|
||||||
A: All sync data for that book is removed from the server.
|
|
||||||
|
|
||||||
**Q: Can I export my reading 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.
|
|
||||||
|
|
||||||
**Q: How much data does sync use?**
|
|
||||||
A: Approximately 1KB per page turn, 50KB per annotation.
|
|
||||||
|
|
||||||
### Technical Questions
|
|
||||||
|
|
||||||
**Q: How does sync handle different page counts?**
|
|
||||||
A: Uses percentage and EPUB CFI for universal positioning.
|
|
||||||
|
|
||||||
**Q: Can I sync with Calibre anymore?**
|
|
||||||
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.
|
|
||||||
|
|
||||||
**Q: Is sync end-to-end encrypted?**
|
|
||||||
A: Yes, HTTPS/TLS 1.3 for all sync traffic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **API Docs**: `/docs/api.md`
|
|
||||||
- **Device Guides**: `/docs/devices/`
|
|
||||||
- **Self-Hosting**: `/docs/install.md`
|
|
||||||
|
|
||||||
### Support
|
|
||||||
|
|
||||||
- **Issues**: Report bugs at GitHub Issues
|
|
||||||
- **Discussions**: Join GitHub Discussions
|
|
||||||
- **Email**: support@bookhoard.example.com
|
|
||||||
|
|
||||||
### Community
|
|
||||||
|
|
||||||
- **Forum**: community.bookhoard.example.com
|
|
||||||
- **Matrix**: #bookhoard:matrix.org
|
|
||||||
- **Discord**: discord.gg/bookhoard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### Version 1.0.x (2026)
|
|
||||||
|
|
||||||
- ✅ 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**: August 2026
|
|
||||||
**Version**: 1.0
|
|
||||||
**License**: AGPL-3.0
|
|
||||||
+8
-2
@@ -34,7 +34,7 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
|||||||
|
|
||||||
**Light**
|
**Light**
|
||||||
|
|
||||||
- **Lupine** - Clean light with violet-blue accents (default light theme)
|
- **Lupine** - Clean light with violet-blue accents
|
||||||
- **Tokyo Night Day** - Daylight counterpart of the default
|
- **Tokyo Night Day** - Daylight counterpart of the default
|
||||||
- **Solarized Light** - Precision contrast on warm paper
|
- **Solarized Light** - Precision contrast on warm paper
|
||||||
- **One Light** - Atom's light theme
|
- **One Light** - Atom's light theme
|
||||||
@@ -52,11 +52,17 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
|||||||
|
|
||||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||||
2. Use the **Dark / Light** switch at the top to show the half you want —
|
2. Use the **Dark / Light** switch at the top to show the half you want —
|
||||||
it opens on the half your current theme lives in
|
it opens on the half your current theme lives in, and each side shows
|
||||||
|
how many themes it holds. The switch only filters the list; it never
|
||||||
|
changes your theme by itself
|
||||||
3. Pick a theme from the list — each option shows its color swatch, and your
|
3. Pick a theme from the list — each option shows its color swatch, and your
|
||||||
active theme is marked with a checkmark
|
active theme is marked with a checkmark
|
||||||
4. Your choice is saved automatically and synced across devices
|
4. Your choice is saved automatically and synced across devices
|
||||||
|
|
||||||
|
There is exactly one default: **Tokyo Night** on a fresh install. Nothing
|
||||||
|
else is pre-selected for you — pick whichever theme you like from either
|
||||||
|
half.
|
||||||
|
|
||||||
On small screens, open the sidebar with the menu button in the top bar first.
|
On small screens, open the sidebar with the menu button in the top bar first.
|
||||||
|
|
||||||
## Wood Paneling
|
## Wood Paneling
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# User Areas Guide
|
|
||||||
|
|
||||||
**Coming Soon**
|
|
||||||
|
|
||||||
This guide will cover:
|
|
||||||
|
|
||||||
- Managing your personal library
|
|
||||||
- Uploading and organizing books
|
|
||||||
- Creating and managing collections
|
|
||||||
- Syncing across devices
|
|
||||||
- Personal settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_In the meantime, check out our [Sync Guide](sync-guide.md)_
|
|
||||||
+38
-70
@@ -1,79 +1,47 @@
|
|||||||
# User Documentation
|
# Bookhoard — start here
|
||||||
|
|
||||||
Welcome to the Bookhoard user documentation. This section contains guides for using Bookhoard features, setting up devices, and configuring sync.
|
Bookhoard is a self-hosted home for the things you read and hear: your book
|
||||||
|
libraries live on your own server, you read them in the browser or on your
|
||||||
|
devices, and everything keeps track of where you are.
|
||||||
|
|
||||||
## 📱 Device Setup
|
The moving parts, in one breath:
|
||||||
|
|
||||||
Learn how to configure your e-reader devices to sync with Bookhoard:
|
- **Libraries** hold your books (and your podcasts run as feeds of their own)
|
||||||
|
- The **dashboard** is your home screen — shelves that update themselves
|
||||||
|
- **Collections** are shelves you build, by hand or by rules
|
||||||
|
- **Filters** answer "which books match?" — the same rules power saved
|
||||||
|
filters, smart collections, and dashboard shelves
|
||||||
|
- **Devices** — the Android app, KOReader on e-readers, and stock Kobo
|
||||||
|
firmware — stay in step with the server through **sync**
|
||||||
|
- Your **profile** holds your preferences: theme, playback defaults, password
|
||||||
|
|
||||||
- **[Android App Guide](devices/android-app.md)** - The native Android client
|
## Where to go
|
||||||
- Installing and upgrading the APK
|
|
||||||
- Connecting to a self-hosted server over LAN
|
|
||||||
- The Android 16+ local-network permission
|
|
||||||
- Troubleshooting login failures
|
|
||||||
|
|
||||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
| You want to... | Read |
|
||||||
- Installation on Kindle/Kobo/PocketBook
|
| --- | --- |
|
||||||
- Plugin setup with server-side device approval
|
| Learn the layout: dashboard, shelves, customizing | [Dashboard](dashboard.md) |
|
||||||
- Progress, bookmark, highlight, and note sync
|
| Browse, search, and sort your books | [Browsing your library](library-browsing.md) |
|
||||||
- OPDS catalog access
|
| Build shelves — by hand or with rules | [Collections](collections.md) |
|
||||||
- Troubleshooting
|
| Narrow with rules, save them, reuse them | [Filters — the complete guide](filters/index.md) |
|
||||||
|
| Subscribe to shows, download, listen | [Podcasts](podcasts.md) |
|
||||||
|
| Read and annotate in the browser | [Web Reader Guide](reader-guide.md) |
|
||||||
|
| Change themes and bookshelf textures | [Themes and Wood Paneling](themes.md) |
|
||||||
|
| Edit your account, password, playback defaults | [Your profile](profile-guide.md) |
|
||||||
|
| Get the Android app talking to your server | [Android App](devices/android-app.md) |
|
||||||
|
| Use KOReader on an e-reader | [KOReader Setup](devices/koreader-setup.md) |
|
||||||
|
| Use a Kobo | [Kobo Setup](devices/kobo-setup.md) |
|
||||||
|
| Understand how it all stays in sync | [Universal Sync Guide](sync-guide.md) |
|
||||||
|
| Import from Calibre | [Calibre Integration](calibre-integration.md) |
|
||||||
|
|
||||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
|
New here? The usual path is: browse [your library](library-browsing.md),
|
||||||
- In the meantime, KOReader works great on Kobo hardware
|
open a book, and let the [dashboard](dashboard.md) keep track of you. Sync
|
||||||
|
comes when you add a [device](devices/android-app.md).
|
||||||
|
|
||||||
## 🔄 Sync Configuration
|
## A note on admins
|
||||||
|
|
||||||
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features
|
Some screens (user management, server settings, library folders) are admin
|
||||||
- What is Universal Sync?
|
territory; [Admin Guide](../admin/admin-guide.md) is the door, and the
|
||||||
- Supported devices
|
Administration docs section covers every admin page.
|
||||||
- Book matching and auto-linking
|
|
||||||
- Conflict resolution
|
|
||||||
- Best practices
|
|
||||||
|
|
||||||
## 📚 Calibre Integration
|
**Looking for developer documentation?** See the
|
||||||
|
[Developer Portal](../developer/development.md).
|
||||||
- **[Calibre Integration Guide](calibre-integration.md)** - Integrating with Calibre libraries
|
|
||||||
- What is Calibre?
|
|
||||||
- Automatic metadata import
|
|
||||||
- Setting up your Calibre library
|
|
||||||
- Supported metadata and file types
|
|
||||||
- Workflow examples
|
|
||||||
- Troubleshooting
|
|
||||||
- Best practices
|
|
||||||
|
|
||||||
## 📖 Reading
|
|
||||||
|
|
||||||
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
|
|
||||||
- Touch navigation (swipe to page, tap for menu)
|
|
||||||
- Selecting text: long-press, drag, handles
|
|
||||||
- Highlights, notes, copy, and editing
|
|
||||||
- PDFs and comics
|
|
||||||
|
|
||||||
## 🎨 Frontend Guide
|
|
||||||
|
|
||||||
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
## 👤 User Areas
|
|
||||||
|
|
||||||
**[User Areas Guide](user-areas.md)** - Managing your personal library and settings
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
## ⚙️ Settings
|
|
||||||
|
|
||||||
**[Settings Guide](settings-guide.md)** - Configuring your Bookhoard preferences
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
## 🔐 Admin Features
|
|
||||||
|
|
||||||
**[Admin Guide](admin-guide.md)** - Administrative functions and management
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Looking for developer documentation?** See the [Developer Portal](../developer/development.md)
|
|
||||||
+15
-14
@@ -640,18 +640,19 @@ type UserDashboardPreferencesGlobal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Users struct {
|
type Users struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
Email string `db:"email" json:"email"`
|
Email string `db:"email" json:"email"`
|
||||||
Username string `db:"username" json:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||||
Role string `db:"role" json:"role"`
|
Role string `db:"role" json:"role"`
|
||||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||||
|
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
|
||||||
}
|
}
|
||||||
@@ -894,6 +894,7 @@ type Querier interface {
|
|||||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||||
// Update universal progress
|
// Update universal progress
|
||||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||||
|
UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error
|
||||||
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||||
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
|
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
|
||||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||||
|
|||||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user