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 | ||
|
|
5fb80f82ed | ||
|
|
57bc0e9dd4 | ||
|
|
96a40f7d88 | ||
|
|
a20741ccc9 | ||
|
|
d56031f334 | ||
|
|
0e29eff7b7 | ||
|
|
65f257f377 | ||
|
|
c448fa438f | ||
|
|
81e54f89b2 | ||
|
|
178b1f30c5 | ||
|
|
cce488b76f | ||
|
|
09f8f0875f | ||
|
|
ed42111df1 | ||
|
|
c52b514036 | ||
|
|
28efaf3fe9 | ||
|
|
7c7e282e79 | ||
|
|
a86bdac87e | ||
|
|
ab98015190 | ||
|
|
aaf57a66a2 | ||
|
|
744d33e13f | ||
|
|
d6619cd0e1 | ||
|
|
8aa66a30fc | ||
|
|
4410a994ef | ||
|
|
aa9bd950f9 | ||
|
|
79b47c9680 | ||
|
|
21142cae5f | ||
|
|
fd716a7dab | ||
|
|
536fb4794c | ||
|
|
cc0e9d7f91 | ||
|
|
d5dbaa48ea | ||
|
|
8aec80cbb7 | ||
|
|
94985adb5e | ||
|
|
7bf5697c71 | ||
|
|
c54430c254 | ||
|
|
94bd69d22d | ||
|
|
4f71eda6be | ||
|
|
eb68672390 | ||
|
|
6b2125a45a | ||
|
|
56871b65ed | ||
|
|
bc91ce68d4 | ||
|
|
962f9089e0 | ||
|
|
69773ec307 | ||
|
|
cc56b9ef2a | ||
|
|
abb7c84833 | ||
|
|
f68fb19afb | ||
|
|
adb93cba9e | ||
|
|
a90e7debfd | ||
|
|
7bf0b1257d | ||
|
|
f7ba4d24d6 | ||
|
|
f47734dd7d | ||
|
|
5fee8bfb4a | ||
|
|
7d5bbd1177 | ||
|
|
0741e98ea5 | ||
|
|
b293741eb1 | ||
|
|
ace9e57d27 | ||
|
|
2fd0828c06 | ||
|
|
15f41e6fd4 | ||
|
|
0da8e0cdf9 | ||
|
|
b574df346a | ||
|
|
f0f1eb6e8f | ||
|
|
5c545534c2 | ||
|
|
0f3c4a8cec | ||
|
|
e3675536aa | ||
|
|
7c7c08748e | ||
|
|
cdf81f6cf4 | ||
|
|
7ea84043fa |
No files matched your search
@@ -87,3 +87,4 @@ session-*.md
|
|||||||
# see docs/contributing/development.md → Media fixtures)
|
# see docs/contributing/development.md → Media fixtures)
|
||||||
internal/services/testdata/
|
internal/services/testdata/
|
||||||
test-results
|
test-results
|
||||||
|
.openchamber/
|
||||||
@@ -145,6 +145,17 @@ every user sees every library unless an admin hides one) is enforced by
|
|||||||
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
|
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
|
||||||
gates may answer 404 as well as 403 (row invisible vs Go-level
|
gates may answer 404 as well as 403 (row invisible vs Go-level
|
||||||
check — contract_pins 2026-10-29).
|
check — contract_pins 2026-10-29).
|
||||||
|
- **Collections RLS (2026-10-31)**: `collections` and
|
||||||
|
`collection_items` are governed too — predicates
|
||||||
|
`app_can_read_collection` / `app_can_write_collection`, PURE over
|
||||||
|
their arguments (never query `collections`, so no policy recursion
|
||||||
|
under FORCE RLS). Reads: owner-or-admin; writes: owner, admin escape
|
||||||
|
for SYSTEM collections only. Future sharing = one EXISTS branch
|
||||||
|
inside the predicate functions, no policy migration. Registration
|
||||||
|
seeding declares the new user's identity (`rls.WithUser` in
|
||||||
|
`CreateDefaultCollectionsForUser` — the JWT does not exist yet);
|
||||||
|
test-harness writes to non-system collections must run as the row's
|
||||||
|
OWNER (`scopeExecAs`/`ownerSeedCtx`, not the admin handle).
|
||||||
|
|
||||||
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
||||||
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
|
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
|
||||||
|
|||||||
@@ -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,41 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Add Auto-Assign Rule
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: POST
|
|
||||||
url: '{{base_url}}/api/collections/{{collection_id}}/rules'
|
|
||||||
auth: inherit
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
data: |-
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
"priority": 1,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
scripts:
|
|
||||||
- type: tests
|
|
||||||
code: |-
|
|
||||||
test("status is 201", function() {
|
|
||||||
expect(res.status).to.eql(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
docs:
|
|
||||||
content: |-
|
|
||||||
# Add Auto-Assign Rule
|
|
||||||
|
|
||||||
`POST /api/collections/:id/rules` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/add_auto_assign_rule.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: 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
|
||||||
@@ -16,7 +16,6 @@ http:
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -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,31 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Remove Auto-Assign Rule
|
|
||||||
type: http
|
|
||||||
seq: 13
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: DELETE
|
|
||||||
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
|
|
||||||
auth: inherit
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
scripts:
|
|
||||||
- type: tests
|
|
||||||
code: |-
|
|
||||||
test("status is 204", function() {
|
|
||||||
expect(res.status).to.eql(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
docs:
|
|
||||||
content: |-
|
|
||||||
# Remove Auto-Assign Rule
|
|
||||||
|
|
||||||
`DELETE /api/collections/:id/rules/:ruleId` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/remove_auto_assign_rule.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: Reorder Collection Items
|
name: Reorder Collection Items
|
||||||
type: http
|
type: http
|
||||||
seq: 14
|
seq: 12
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
info:
|
||||||
|
name: Set Collection Chain Order
|
||||||
|
type: http
|
||||||
|
seq: 13
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/collections/{{collection_id}}/view-settings'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"chain_order": "newest"
|
||||||
|
}
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs:
|
||||||
|
content: |-
|
||||||
|
# Set Collection Chain Order
|
||||||
|
|
||||||
|
`PUT /api/collections/:id/view-settings` — auth: jwt.
|
||||||
|
|
||||||
|
Canonical reference: docs/developer/api/collections/set_chain_order.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,48 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Test Rule
|
|
||||||
type: http
|
|
||||||
seq: 15
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: POST
|
|
||||||
url: '{{base_url}}/api/collections/test-rules'
|
|
||||||
auth: inherit
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
data: |-
|
|
||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
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: |-
|
|
||||||
# Test Rule
|
|
||||||
|
|
||||||
`POST /api/collections/test-rules` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/test_rule.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: Update Collection
|
name: Update Collection
|
||||||
type: http
|
type: http
|
||||||
seq: 16
|
seq: 14
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: PUT
|
method: PUT
|
||||||
@@ -15,7 +15,6 @@ http:
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
info:
|
|
||||||
name: Update Collection Rule
|
|
||||||
type: http
|
|
||||||
seq: 17
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: PUT
|
|
||||||
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
|
|
||||||
auth: inherit
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
data: |-
|
|
||||||
{
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
docs:
|
|
||||||
content: |-
|
|
||||||
# Update Collection Rule
|
|
||||||
|
|
||||||
`PUT /api/collections/:id/rules/:ruleId` — auth: jwt.
|
|
||||||
|
|
||||||
Canonical reference: docs/developer/api/collections/update_rule.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
|
|
||||||
+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
|
||||||
|
|||||||
+11
-5
@@ -170,19 +170,25 @@ func main() {
|
|||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
opdsHandler.SetSettings(registry)
|
opdsHandler.SetSettings(registry)
|
||||||
|
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
// One composed-engine service shared by search AND the collection
|
||||||
dashboardService := services.NewDashboardService(queries)
|
// subsystem (item 3.2): tree-driven smart collections evaluate
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
// through the unified engine.
|
||||||
|
collectionSearchService := services.NewSearchService(queries, scopedDB)
|
||||||
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
|
||||||
|
dashboardService := services.NewDashboardService(queries, collectionSearchService)
|
||||||
|
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
|
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
// Podcast subsystem: fetcher service + sequential download worker +
|
// Podcast subsystem: fetcher service + concurrent download worker
|
||||||
// poller. One instance trio per server (per-feed locks dedupe).
|
// (§0 item 4: pool, fair share, admin tunables) + poller. One
|
||||||
|
// instance trio per server (per-feed locks dedupe).
|
||||||
podcastService := services.NewPodcastService(queries)
|
podcastService := services.NewPodcastService(queries)
|
||||||
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
|
||||||
|
podcastWorker.SetSettings(registry)
|
||||||
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|||||||
@@ -659,12 +659,14 @@ func TestAudiobookFolderSidecarEditDetected(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
|
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
var collCount int
|
var collCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
// Owner-scoped read: collections RLS (2026-10-31) hides rows from
|
||||||
|
// the pool session's SeedIdentity.
|
||||||
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
|
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collections RLS (2026-10-31): ownership enforcement moved to the DB
|
||||||
|
// layer (p_collections_* + parent-checked collection_items policies in
|
||||||
|
// schema.sql, predicates app_can_read/write_collection). The API surface
|
||||||
|
// answers invisibility with 404 — including the write endpoints that
|
||||||
|
// had NO app-level gate (Update/AddBooks, the flagged gap). Admin keeps
|
||||||
|
// the escape; system collections stay per-user; registration still
|
||||||
|
// seeds them (the seeding identity fix in
|
||||||
|
// CreateDefaultCollectionsForUser is regression-pinned here).
|
||||||
|
func TestCollectionsRLS(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ownerToken := setup.RegularToken // user A: the harness regular user
|
||||||
|
adminToken := setup.Token
|
||||||
|
marker := fmt.Sprintf("rls%d", time.Now().UnixNano()%100000)
|
||||||
|
|
||||||
|
// User B: a second regular user via registration. The register
|
||||||
|
// endpoint is rate-limited — exactly one call. The response's
|
||||||
|
// access_token is B's identity for every probe below.
|
||||||
|
regEmail := fmt.Sprintf("%s@tests.bookhoard.internal", marker)
|
||||||
|
regBody := fmt.Sprintf(
|
||||||
|
`{"email":%q,"username":%q,"password":"Test@Pass123!"}`,
|
||||||
|
regEmail, "rlsb"+marker)
|
||||||
|
regResp, err := http.Post(setup.Server.URL+"/api/auth/register",
|
||||||
|
"application/json", bytes.NewReader([]byte(regBody)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusCreated, regResp.StatusCode,
|
||||||
|
"registration must succeed — including the system-collection seeding that now runs under the new user's RLS identity")
|
||||||
|
var reg struct {
|
||||||
|
Token string `json:"access_token"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(regResp.Body).Decode(®))
|
||||||
|
require.NotEmpty(t, reg.Token)
|
||||||
|
otherToken := reg.Token
|
||||||
|
_ = regResp.Body.Close()
|
||||||
|
|
||||||
|
// B's registration seeded B's OWN system collections — visible in
|
||||||
|
// B's list (this is the registration-seeding identity fix, live).
|
||||||
|
code, body := podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, string(body), "Continue Reading",
|
||||||
|
"registration seeded the new user's system collections under RLS")
|
||||||
|
|
||||||
|
// A creates the target collection.
|
||||||
|
code, body = podcastAuthed(t, setup, ownerToken, "POST", "/api/collections",
|
||||||
|
`{"name":"test RLS Owner ` + marker + `"}`)
|
||||||
|
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, ownerToken, "DELETE", "/api/collections/"+coll.ID, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── B (non-owner) probes A's collection: invisible = 404 ──
|
||||||
|
code, _ = podcastAuthed(t, setup, otherToken, "GET", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusNotFound, code, "detail-by-id is RLS-invisible cross-user")
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
assert.NotContains(t, string(body), "test RLS Owner "+marker,
|
||||||
|
"the list query stays user-scoped")
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+coll.ID,
|
||||||
|
`{"name":"renamed by B"}`)
|
||||||
|
require.Equal(t, http.StatusNotFound, code,
|
||||||
|
"update of an RLS-invisible row answers 404 (was: ungated success): %s", string(body))
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "POST", "/api/collections/"+coll.ID+"/books",
|
||||||
|
`{"book_ids":[]}`)
|
||||||
|
require.Equal(t, http.StatusNotFound, code,
|
||||||
|
"AddBooks on an RLS-invisible collection answers 404 (the flagged ungated-write gap, closed at the DB)")
|
||||||
|
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "PUT",
|
||||||
|
"/api/collections/"+coll.ID+"/view-settings", `{"chain_order":"newest"}`)
|
||||||
|
require.Equal(t, http.StatusNotFound, code,
|
||||||
|
"chain-order write on an invisible collection answers 404")
|
||||||
|
|
||||||
|
code, _ = podcastAuthed(t, setup, otherToken, "DELETE", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusNoContent, code,
|
||||||
|
"DELETE touches no rows under RLS (silent no-op — no destructive action)")
|
||||||
|
|
||||||
|
// The no-op delete lied about nothing: A still owns the collection.
|
||||||
|
code, body = podcastAuthed(t, setup, ownerToken, "GET", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code, "owner retains the collection after B's failed delete")
|
||||||
|
|
||||||
|
// ── admin escape: sees everything, as with media ──
|
||||||
|
code, _ = podcastAuthed(t, setup, adminToken, "GET", "/api/collections/"+coll.ID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code, "admin persona sees collections (policy escape)")
|
||||||
|
|
||||||
|
// ── B's ownership works: B renames B's OWN system collection ──
|
||||||
|
// (positive control — B is not locked out of what B owns)
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
var list struct {
|
||||||
|
Collections []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"collections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &list))
|
||||||
|
var bSystem string
|
||||||
|
for _, c := range list.Collections {
|
||||||
|
if c.Name == "Continue Reading" {
|
||||||
|
bSystem = c.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, bSystem, "B owns the seeded system collections")
|
||||||
|
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+bSystem,
|
||||||
|
`{"name":"Continue Reading"}`)
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, code,
|
||||||
|
"owner mutation of an OWN collection still works: %s", string(body))
|
||||||
|
}
|
||||||
@@ -56,36 +56,6 @@ func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionI
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
|
|
||||||
t.Helper()
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"field": field,
|
|
||||||
"operator": operator,
|
|
||||||
"value": value,
|
|
||||||
}
|
|
||||||
if enabled != nil {
|
|
||||||
payload["enabled"] = *enabled
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(payload)
|
|
||||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
|
|
||||||
t.Helper()
|
|
||||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
|
||||||
var result map[string]interface{}
|
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
|
||||||
rule := result["rule"].(map[string]interface{})
|
|
||||||
return rule["id"].(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data helpers -----------------------------------------------------------
|
|
||||||
|
|
||||||
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -145,81 +115,83 @@ func TestSmartCollections(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
adminID := getTestUserID(t, setup.DB)
|
adminID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
|
t.Run("TreeCollectionLiveMembership", func(t *testing.T) {
|
||||||
|
// The unified engine (item 3.2): a collection created WITH a
|
||||||
|
// books-scope tree evaluates LIVE — stored links are ignored,
|
||||||
|
// only tree matches render.
|
||||||
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
"Smart SF Book One": "Science Fiction",
|
"Smart SF Book One": "Science Fiction",
|
||||||
"Smart SF Book Two": "Science Fiction",
|
"Smart SF Book Two": "Science Fiction",
|
||||||
"Smart FANT Book": "Fantasy",
|
"Smart FANT Book": "Fantasy",
|
||||||
})
|
})
|
||||||
|
_ = bookIDs
|
||||||
|
|
||||||
// Rule evaluation ignores stored links: a static collection's own
|
tree := map[string]interface{}{
|
||||||
// added item must NOT show up once the collection is smart.
|
"scope": "books", "mode": "all",
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
|
"children": []map[string]interface{}{
|
||||||
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
// Add one hand-picked book (a Fantasy one that the smart rule
|
},
|
||||||
// will not match — so it must vanish from the render).
|
}
|
||||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
"name": "Test Smart Tree " + uuid.NewString()[:8],
|
||||||
addReq.Header.Set("Content-Type", "application/json")
|
"filter_tree": tree,
|
||||||
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
})
|
||||||
addResp, err := http.DefaultClient.Do(addReq)
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
|
||||||
_ = addResp.Body.Close()
|
|
||||||
|
|
||||||
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
||||||
assert.Equal(t, false, staticDetail["is_smart"])
|
|
||||||
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil)
|
||||||
|
del.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
_, _ = http.DefaultClient.Do(del)
|
||||||
|
})
|
||||||
|
|
||||||
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
detail := smartGetCollection(t, setup, setup.Token, created.ID)
|
||||||
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
|
assert.Equal(t, true, detail["is_smart"], "tree presence derives the type")
|
||||||
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
|
memberTitles := smartLibraryTitleSet(t, detail)
|
||||||
|
|
||||||
memberTitles := smartLibraryTitleSet(t, smartDetail)
|
|
||||||
assert.True(t, memberTitles["Smart SF Book One"])
|
assert.True(t, memberTitles["Smart SF Book One"])
|
||||||
assert.True(t, memberTitles["Smart SF Book Two"])
|
assert.True(t, memberTitles["Smart SF Book Two"])
|
||||||
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
|
assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live")
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
|
|
||||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
|
||||||
"Smart Diss Book": "Science Fiction",
|
|
||||||
})
|
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
ruleID := smartDecodeRuleID(t, resp)
|
|
||||||
|
|
||||||
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
|
|
||||||
|
|
||||||
// Disable: membership drops to zero at once.
|
|
||||||
falseFlag := false
|
|
||||||
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
|
|
||||||
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
|
|
||||||
dReq.Header.Set("Content-Type", "application/json")
|
|
||||||
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
dResp, err := http.DefaultClient.Do(dReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, dResp.StatusCode)
|
|
||||||
_ = dResp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
||||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
"Smart Guard Book": "Science Fiction",
|
"Smart Guard Book": "Science Fiction",
|
||||||
})
|
})
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
|
tree := map[string]interface{}{
|
||||||
|
"scope": "books", "mode": "all",
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
"children": []map[string]interface{}{
|
||||||
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"name": "Test Smart Guard " + uuid.NewString()[:8],
|
||||||
|
"filter_tree": tree,
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
collID := created.ID
|
||||||
|
t.Cleanup(func() {
|
||||||
|
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
|
||||||
|
del.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
_, _ = http.DefaultClient.Do(del)
|
||||||
|
})
|
||||||
|
|
||||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
@@ -238,53 +210,10 @@ func TestSmartCollections(t *testing.T) {
|
|||||||
_ = removeResp.Body.Close()
|
_ = removeResp.Body.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
|
t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) {
|
||||||
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
// System collections may never carry rules/trees — the editor
|
||||||
"Smart Back Book": "Science Fiction",
|
// route refuses them (item 3.4: the page IS the editor).
|
||||||
})
|
colls, err := setup.DB.GetCollectionsByUser(ownerSeedCtx(pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
ruleID := smartDecodeRuleID(t, resp)
|
|
||||||
|
|
||||||
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
||||||
assert.Equal(t, true, smartDetail["is_smart"])
|
|
||||||
|
|
||||||
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
|
|
||||||
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
delResp, err := http.DefaultClient.Do(delReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, delResp.StatusCode)
|
|
||||||
_ = delResp.Body.Close()
|
|
||||||
|
|
||||||
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
||||||
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
|
|
||||||
|
|
||||||
// Membership mutations work again on the reverted static.
|
|
||||||
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
|
||||||
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
|
||||||
addReq.Header.Set("Content-Type", "application/json")
|
|
||||||
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
addResp, err := http.DefaultClient.Do(addReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
|
||||||
_ = addResp.Body.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
|
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
|
|
||||||
|
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
|
|
||||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
// System collections may never carry rules.
|
|
||||||
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var systemID string
|
var systemID string
|
||||||
for _, c := range colls {
|
for _, c := range colls {
|
||||||
@@ -295,20 +224,46 @@ func TestSmartCollections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NotEmpty(t, systemID, "admin user should have system collections")
|
require.NotEmpty(t, systemID, "admin user should have system collections")
|
||||||
|
|
||||||
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil)
|
||||||
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
_ = sysResp.Body.Close()
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||||
|
"system collections never render the smart editor")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
||||||
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
"Smart List Book": "Science Fiction",
|
"Smart List Book": "Science Fiction",
|
||||||
})
|
})
|
||||||
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
|
tree := map[string]interface{}{
|
||||||
|
"scope": "books", "mode": "all",
|
||||||
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
"children": []map[string]interface{}{
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
_ = resp.Body.Close()
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"name": "Test Smart List " + uuid.NewString()[:8],
|
||||||
|
"filter_tree": tree,
|
||||||
|
})
|
||||||
|
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created))
|
||||||
|
_ = resp2.Body.Close()
|
||||||
|
collID := created.ID
|
||||||
|
t.Cleanup(func() {
|
||||||
|
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
|
||||||
|
del.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
_, _ = http.DefaultClient.Do(del)
|
||||||
|
})
|
||||||
|
|
||||||
// Default list (the Android app's read): smart collections hidden.
|
// Default list (the Android app's read): smart collections hidden.
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +465,8 @@ var bookInfoKeys = []string{
|
|||||||
"media_item_id", "title", "author", "cover_image_path",
|
"media_item_id", "title", "author", "cover_image_path",
|
||||||
"has_conflict", "format_group", "badge",
|
"has_conflict", "format_group", "badge",
|
||||||
"viewer_percentage", "library_name",
|
"viewer_percentage", "library_name",
|
||||||
|
// Episode-collections extras (2.3 widening, every surface emits):
|
||||||
|
"is_episode", "feed_title", "download_state", "hidden",
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionURL(setup *TestServerSetup, id string) string {
|
func collectionURL(setup *TestServerSetup, id string) string {
|
||||||
@@ -498,8 +506,9 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
|||||||
// payload; docs/contract_pins.md changelog).
|
// payload; docs/contract_pins.md changelog).
|
||||||
envelopeKeys := []string{
|
envelopeKeys := []string{
|
||||||
"id", "user_id", "name", "description", "color", "icon",
|
"id", "user_id", "name", "description", "color", "icon",
|
||||||
"auto_assign_rules", "view_settings", "books", "book_count",
|
"view_settings", "books", "book_count",
|
||||||
"is_smart", "created_at", "up_next",
|
"is_smart", "created_at", "up_next",
|
||||||
|
"filter_tree", // item-3.2 widening (docs/contract_pins.md)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
|
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
|
||||||
@@ -562,11 +571,14 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
|||||||
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
|
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("smart collection (rules branch)", func(t *testing.T) {
|
t.Run("smart collection (tree branch)", func(t *testing.T) {
|
||||||
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
|
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
|
||||||
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
|
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
|
||||||
"auto_assign_rules": []map[string]interface{}{
|
"filter_tree": map[string]interface{}{
|
||||||
{"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5},
|
"scope": "books", "mode": "any",
|
||||||
|
"children": []map[string]interface{}{
|
||||||
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, setup.Token)
|
}, setup.Token)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -761,6 +773,10 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
|||||||
"declared_duration_seconds", "transcript_url", "chapters_url",
|
"declared_duration_seconds", "transcript_url", "chapters_url",
|
||||||
"hosts", "guests", "download_state", "download_error",
|
"hosts", "guests", "download_state", "download_error",
|
||||||
"download_attempts",
|
"download_attempts",
|
||||||
|
// §0 item 4 (2026-10-30 additive widening): the download pool's
|
||||||
|
// queue ticket — SEQUENCE-stamped at enqueue, NULL for scan rows
|
||||||
|
// and never-queued episodes. See docs/contract_pins.md changelog.
|
||||||
|
"download_queue_pos",
|
||||||
}
|
}
|
||||||
resolvedKeys := []string{
|
resolvedKeys := []string{
|
||||||
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
||||||
@@ -1198,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,157 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The custom-section builder's save, end to end (2026-10-31 tree
|
||||||
|
// migration). The page posts the legacy rules vocabulary — rules as a
|
||||||
|
// JSON-encoded ARRAY inside a STRING (it has done so since the page was
|
||||||
|
// written, 2026-02-19; the save never once bound successfully before
|
||||||
|
// the migration) — plus match_type, hand-picked book ids, and the
|
||||||
|
// dashboard pin. The server converts the rules to a filter tree at
|
||||||
|
// write time, attaches the manual picks, and the dashboard section
|
||||||
|
// renders stored membership + the tree's live matches, deduped.
|
||||||
|
func TestCustomSectionBuilderSave(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
adminToken := setup.Token
|
||||||
|
regToken := setup.RegularToken
|
||||||
|
marker := fmt.Sprintf("csb%d", time.Now().UnixNano()%100000)
|
||||||
|
|
||||||
|
libID := createTestLibraryWithFolder(t, setup.Server, adminToken, "test CSB Library "+marker, true)
|
||||||
|
libUUID, err := uuid.Parse(libID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Two items the title rule matches, one it doesn't. Alpha is ALSO
|
||||||
|
// hand-picked — the section must render it once, not twice.
|
||||||
|
makeItem := func(title string) uuid.UUID {
|
||||||
|
id, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
|
Title: title,
|
||||||
|
Authors: []string{"CSB Author"},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/csb/%s-%s.epub", marker, strings.ReplaceAll(title, " ", "-")),
|
||||||
|
FileSize: pgtype.Int8{Int64: 128, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, cerr)
|
||||||
|
out, perr := uuid.FromBytes(id.ID.Bytes[:])
|
||||||
|
require.NoError(t, perr)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
alpha := makeItem("CSBRULE Alpha")
|
||||||
|
_ = makeItem("CSBRULE Beta")
|
||||||
|
manual := makeItem("CSB Manual Pick")
|
||||||
|
require.NotZero(t, alpha)
|
||||||
|
|
||||||
|
// The EXACT payload shape the builder page posts.
|
||||||
|
payload := fmt.Sprintf(`{
|
||||||
|
"library_id": %q,
|
||||||
|
"name": "test CSB Section %s",
|
||||||
|
"icon": "📚",
|
||||||
|
"description": "built by the custom-section builder",
|
||||||
|
"show_on_dashboard": true,
|
||||||
|
"auto_assign_rules": %[3]s,
|
||||||
|
"manual_book_ids": [%q, %q],
|
||||||
|
"match_type": "all"
|
||||||
|
}`, libID, marker,
|
||||||
|
`"[{\"field\":\"title\",\"operator\":\"contains\",\"value\":\"CSBRULE\"}]"`,
|
||||||
|
alpha.String(), manual.String())
|
||||||
|
|
||||||
|
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
|
||||||
|
require.Equal(t, http.StatusCreated, code, "%s", string(body))
|
||||||
|
var coll struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(body), &coll))
|
||||||
|
collID := coll.ID
|
||||||
|
require.NotEmpty(t, collID)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// The converted tree round-trips: root mode from match_type, the
|
||||||
|
// rule as a qf child, and the collection IS smart with no
|
||||||
|
// collection_type sent.
|
||||||
|
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
var detail struct {
|
||||||
|
IsSmart bool `json:"is_smart"`
|
||||||
|
FilterTree struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Children []struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Values []string `json:"values"`
|
||||||
|
} `json:"children"`
|
||||||
|
} `json:"filter_tree"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(body), &detail))
|
||||||
|
assert.True(t, detail.IsSmart, "converted rules make the collection smart")
|
||||||
|
assert.Equal(t, "all", detail.FilterTree.Mode, "match_type seeds the tree's root mode")
|
||||||
|
require.Len(t, detail.FilterTree.Children, 1)
|
||||||
|
assert.Equal(t, "title", detail.FilterTree.Children[0].Field)
|
||||||
|
assert.Equal(t, []string{"CSBRULE"}, detail.FilterTree.Children[0].Values)
|
||||||
|
|
||||||
|
// The dashboard section renders stored membership + live matches,
|
||||||
|
// deduped: Alpha (rule + manual), Beta (rule), Manual (manual).
|
||||||
|
code, body = podcastAuthed(t, setup, regToken, "GET",
|
||||||
|
"/api/dashboard/sections?library_id="+libID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code, "%s", string(body))
|
||||||
|
var dash struct {
|
||||||
|
Sections []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
CollectionID string `json:"collection_id"`
|
||||||
|
Items []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"sections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(body), &dash))
|
||||||
|
var section *struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
CollectionID string `json:"collection_id"`
|
||||||
|
Items []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
for i := range dash.Sections {
|
||||||
|
if dash.Sections[i].CollectionID == collID {
|
||||||
|
section = &dash.Sections[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, section, "the custom section renders on the dashboard: %s", string(body))
|
||||||
|
titles := map[string]int{}
|
||||||
|
for _, it := range section.Items {
|
||||||
|
titles[it.Title]++
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, titles["CSBRULE Alpha"], "rule-matched AND hand-picked renders exactly once")
|
||||||
|
assert.Equal(t, 1, titles["CSBRULE Beta"], "rule match rides the tree")
|
||||||
|
assert.Equal(t, 1, titles["CSB Manual Pick"], "manual picks are attached")
|
||||||
|
assert.Len(t, section.Items, 3, "nothing else leaks into the section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomSectionBuilderBadRule pins the adapter's rejection path: a
|
||||||
|
// rule the engine cannot honor is a 400 that names the field, never a
|
||||||
|
// stored 500 at render time.
|
||||||
|
func TestCustomSectionBuilderBadRule(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
regToken := setup.RegularToken
|
||||||
|
|
||||||
|
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
|
||||||
|
`{"name":"test CSB Bad `+fmt.Sprint(time.Now().UnixNano())+`","auto_assign_rules":"[{\"field\":\"not_a_field\",\"operator\":\"contains\",\"value\":\"x\"}]"}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
|
||||||
|
assert.Contains(t, string(body), "section rule")
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -295,6 +295,115 @@ func TestFilterComposedSemantics(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
|
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFilterScopesAndEpisodeFields pins item 3.1: the books scope
|
||||||
|
// excludes podcasts (the leak the composed head used to have), the
|
||||||
|
// episodes scope is episodes only, episode fields evaluate in SQL
|
||||||
|
// (feed-in, downloaded, played, published-date-within), and episode
|
||||||
|
// fields are 400s in books trees.
|
||||||
|
func TestFilterScopesAndEpisodeFields(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
regUUID := getRegularUserID(t, setup.DB)
|
||||||
|
builderFixtureLib(t, setup, regUUID, "Scope")
|
||||||
|
|
||||||
|
// Podcasts library + a subscribed feed with three downloaded
|
||||||
|
// episodes: two fresh/unplayed, one played (~finished) — the
|
||||||
|
// played-rule fixture.
|
||||||
|
feedLib, _ := createPodcastsLibrary(t, setup, "test Scope Podcasts")
|
||||||
|
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||||
|
LibraryID: feedLib.ID,
|
||||||
|
FeedUrl: "https://feed.invalid/scope-probe.xml",
|
||||||
|
Title: "Scope Probe Feed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
epFresh := upsertTestEpisode(t, setup, feed, "scope-fresh", "Scope Fresh Episode", 1)
|
||||||
|
epPlayed := upsertTestEpisode(t, setup, feed, "scope-played", "Scope Played Episode", 2)
|
||||||
|
upsertTestEpisode(t, setup, feed, "scope-pending", "Scope Pending Episode", 3)
|
||||||
|
subscribeRaw(t, setup, regUUID, feed.ID)
|
||||||
|
adminScopeExec(t, setup, adminCtxValue(t, setup), `
|
||||||
|
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
|
||||||
|
date_published = CURRENT_DATE WHERE id IN ($1, $2)`, epFresh.ID, epPlayed.ID)
|
||||||
|
adminScopeExec(t, setup, adminCtxValue(t, setup), `
|
||||||
|
INSERT INTO reading_progress (media_item_id, user_id, percentage)
|
||||||
|
VALUES ($1, $2, 1.0) ON CONFLICT (media_item_id, user_id)
|
||||||
|
DO UPDATE SET percentage = 1.0`, epPlayed.ID, regUUID)
|
||||||
|
|
||||||
|
feedID := uuid.UUID(feed.ID.Bytes).String()
|
||||||
|
|
||||||
|
qfScoped := func(scope string, children ...map[string]interface{}) url.Values {
|
||||||
|
b, _ := json.Marshal(map[string]interface{}{"scope": scope, "mode": "all", "children": children})
|
||||||
|
return url.Values{"qf": {string(b)}}
|
||||||
|
}
|
||||||
|
const booksScope = "books"
|
||||||
|
const episodesScope = "episodes"
|
||||||
|
|
||||||
|
search := func(p url.Values) []string {
|
||||||
|
rows := p
|
||||||
|
status, body := qfSearch(t, setup, setup.RegularToken, rows)
|
||||||
|
// 404 is the endpoint's pinned no-results contract — an empty set.
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status, "qf must parse: %v", rows)
|
||||||
|
return qfTitles(t, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("books_scope_excludes_podcasts", func(t *testing.T) {
|
||||||
|
// An exact-ish title hits BOTH a book fixture ("Scope Tide") and
|
||||||
|
// the episode fixture if the head leaks; books scope must return
|
||||||
|
// the book, never the episodes.
|
||||||
|
got := search(qfScoped(booksScope, qfRule("title", "is", "Scope Tide")))
|
||||||
|
assert.Equal(t, []string{"Scope Tide"}, got, "books scope returns the book")
|
||||||
|
got = search(qfScoped(booksScope, qfRule("title", "is", "Scope Fresh Episode")))
|
||||||
|
assert.Empty(t, got,
|
||||||
|
"books scope must never return podcast episodes (the composed-head leak is CLOSED)")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("episodes_scope_is_episodes_only", func(t *testing.T) {
|
||||||
|
got := search(qfScoped(episodesScope, qfRule("title", "is", "Scope Fresh Episode")))
|
||||||
|
assert.Equal(t, []string{"Scope Fresh Episode"}, got,
|
||||||
|
"episodes scope: only episodes — books drop")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("episode_rules_evaluate_in_sql", func(t *testing.T) {
|
||||||
|
got := search(qfScoped(episodesScope,
|
||||||
|
qfRule("feed", "in", feedID)))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode", "Scope Pending Episode"},
|
||||||
|
got, "feed-in matches every episode of the feed (download state is a separate rule)")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("downloaded", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
|
||||||
|
got, "downloaded:true drops the pending episode")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("played", "is_false"),
|
||||||
|
qfRule("downloaded", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode"}, got,
|
||||||
|
"played:false + downloaded = the unplayed playable episode (viewer progress)")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("played", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Played Episode"}, got,
|
||||||
|
"played:true matches the finished episode")
|
||||||
|
|
||||||
|
got = search(qfScoped(episodesScope,
|
||||||
|
qfRule("published_date", "within_days", "7"),
|
||||||
|
qfRule("downloaded", "is_true")))
|
||||||
|
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
|
||||||
|
got, "published-within includes the dated episodes")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("episode_fields_are_400_in_books_scope", func(t *testing.T) {
|
||||||
|
p := qfScoped(booksScope, qfRule("played", "is_true"))
|
||||||
|
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.StatusBadRequest, resp.StatusCode,
|
||||||
|
"episode-only fields are rejected in books trees at parse time")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFilterUserRelativeFields(t *testing.T) {
|
func TestFilterUserRelativeFields(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
t.Cleanup(func() { _ = setup.Close() })
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
@@ -346,12 +455,17 @@ func TestFilterUserRelativeFields(t *testing.T) {
|
|||||||
aID, wickID, aID, tideID)
|
aID, wickID, aID, tideID)
|
||||||
|
|
||||||
var collID pgtype.UUID
|
var collID pgtype.UUID
|
||||||
err := setup.DBPool.QueryRow(context.Background(),
|
// Seed as the OWNER (collections RLS, 2026-10-31): the insert
|
||||||
|
// predicate wants actor == owner, and the item/cleanup writes want
|
||||||
|
// the same identity — the admin write-escape covers SYSTEM rows only.
|
||||||
|
err := database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(aID),
|
||||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||||
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
t.Cleanup(func() {
|
||||||
seedExec(t, setup.DBPool,
|
scopeExecAs(t, setup, aID, `DELETE FROM collections WHERE id = $1`, collID)
|
||||||
|
})
|
||||||
|
scopeExecAs(t, setup, aID,
|
||||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
||||||
|
|
||||||
titlesFor := func(token string, qf string) []string {
|
titlesFor := func(token string, qf string) []string {
|
||||||
@@ -433,7 +547,6 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
|||||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
|
||||||
|
|
||||||
// Add a second distinct author+genre pair (trigram-disjoint).
|
// Add a second distinct author+genre pair (trigram-disjoint).
|
||||||
ctx := context.Background()
|
|
||||||
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: "Sug Dune",
|
Title: "Sug Dune",
|
||||||
@@ -537,6 +650,16 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.True(t, found, "the visible library is offered")
|
assert.True(t, found, "the visible library is offered")
|
||||||
|
|
||||||
|
// ID LOOKUP (2026-11-01): typing a stored ID resolves its label —
|
||||||
|
// the builder's post-Apply reload path resolves stored ids to names
|
||||||
|
// through this same call.
|
||||||
|
status, results = suggest("library", libUUID.String(), "")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.NotEmpty(t, results, "the id lookup returns its row")
|
||||||
|
assert.Equal(t, libUUID.String(), results[0]["value"])
|
||||||
|
disp, _ := results[0]["display"].(string)
|
||||||
|
assert.Contains(t, disp, "Test Builder", "the id resolves to the library NAME")
|
||||||
|
|
||||||
// BROWSE the library field UNDER rules (the blank-typed click): the
|
// BROWSE the library field UNDER rules (the blank-typed click): the
|
||||||
// id-source query must not choke on the unused typed parameter and
|
// id-source query must not choke on the unused typed parameter and
|
||||||
// must list exactly the library holding the surviving books.
|
// must list exactly the library holding the surviving books.
|
||||||
@@ -561,16 +684,21 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
|
|||||||
// offered with the surviving count; zero-match containers never are.
|
// offered with the surviving count; zero-match containers never are.
|
||||||
userA := getRegularUserID(t, setup.DB)
|
userA := getRegularUserID(t, setup.DB)
|
||||||
var collID pgtype.UUID
|
var collID pgtype.UUID
|
||||||
err = setup.DBPool.QueryRow(context.Background(),
|
// Seed as the OWNER (collections RLS, 2026-10-31) — same identity
|
||||||
|
// rule as the first seeding site in this file.
|
||||||
|
ownerID := pgtype.UUID{Bytes: [16]byte(userA), Valid: true}
|
||||||
|
err = database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(ownerID),
|
||||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||||
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
|
ownerID,
|
||||||
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
t.Cleanup(func() {
|
||||||
|
scopeExecAs(t, setup, ownerID, `DELETE FROM collections WHERE id = $1`, collID)
|
||||||
|
})
|
||||||
var tideID pgtype.UUID
|
var tideID pgtype.UUID
|
||||||
err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
|
err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
seedExec(t, setup.DBPool,
|
scopeExecAs(t, setup, ownerID,
|
||||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
|
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
|
||||||
|
|
||||||
status, results = suggest("collection", " ", "")
|
status, results = suggest("collection", " ", "")
|
||||||
@@ -775,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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
||||||
@@ -67,13 +69,14 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
require.NotEmpty(t, foundPath)
|
require.NotEmpty(t, foundPath)
|
||||||
|
|
||||||
// (b) The playlist collection exists with provenance, dashboard pin,
|
// (b) The playlist collection exists with provenance, dashboard pin,
|
||||||
// and the item at reading position 7.
|
// and the item at reading position 7. Owner-scoped read: collections
|
||||||
|
// RLS (2026-10-31) hides the row from the pool session's SeedIdentity.
|
||||||
adminUUIDStr := adminID.String()
|
adminUUIDStr := adminID.String()
|
||||||
var collID uuid.UUID
|
var collID uuid.UUID
|
||||||
var collName, collDesc string
|
var collName, collDesc string
|
||||||
var showOnDashboard bool
|
var showOnDashboard bool
|
||||||
var viewSettingsRaw []byte
|
var viewSettingsRaw []byte
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT id, name, description, show_on_dashboard, view_settings
|
`SELECT id, name, description, show_on_dashboard, view_settings
|
||||||
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||||
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
||||||
@@ -85,7 +88,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
|
|
||||||
var position *int32
|
var position *int32
|
||||||
var itemCount int
|
var itemCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
// Owner-scoped reads: collections RLS (2026-10-31) — items ride the
|
||||||
|
// parent's readability, so the pool session's SeedIdentity sees none.
|
||||||
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
||||||
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
||||||
require.Equal(t, 1, itemCount)
|
require.Equal(t, 1, itemCount)
|
||||||
@@ -96,11 +101,11 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
// (c) Rescan: no duplicate collection, no duplicate membership.
|
// (c) Rescan: no duplicate collection, no duplicate membership.
|
||||||
scanTestFolder(t, setup, adminID, folder)
|
scanTestFolder(t, setup, adminID, folder)
|
||||||
var collectionCount, membershipCount int
|
var collectionCount, membershipCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||||
adminID).Scan(&collectionCount))
|
adminID).Scan(&collectionCount))
|
||||||
require.Equal(t, 1, collectionCount)
|
require.Equal(t, 1, collectionCount)
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||||
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
||||||
require.Equal(t, 1, membershipCount)
|
require.Equal(t, 1, membershipCount)
|
||||||
|
|
||||||
@@ -131,7 +136,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
|||||||
// (e) hide_finished + Up Next + completion marker.
|
// (e) hide_finished + Up Next + completion marker.
|
||||||
// Flip the collection's flag on, mark the single item finished for the
|
// Flip the collection's flag on, mark the single item finished for the
|
||||||
// admin, and the dashboard section becomes Complete with no items.
|
// admin, and the dashboard section becomes Complete with no items.
|
||||||
_, err = setup.DBPool.Exec(context.Background(),
|
// Owner-or-admin UPDATE policy: run as ADMIN (collections RLS,
|
||||||
|
// 2026-10-31).
|
||||||
|
adminScopeExec(t, setup, adminCtxValue(t, setup),
|
||||||
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = setup.DBPool.Exec(context.Background(),
|
_, err = setup.DBPool.Exec(context.Background(),
|
||||||
|
|||||||
+1785
-14
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -207,12 +207,13 @@ func TestScanManifestLifecycle(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
|
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
var collCount int
|
var collCount int
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
// Owner-scoped read: collections RLS (2026-10-31).
|
||||||
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
|
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
|
||||||
scanTestFolder(t, setup, admin, folder)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
|
||||||
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
||||||
).Scan(&collCount))
|
).Scan(&collCount))
|
||||||
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -329,6 +366,22 @@ func seedCtx() context.Context {
|
|||||||
return rls.WithUser(context.Background(), rls.SeedIdentity)
|
return rls.WithUser(context.Background(), rls.SeedIdentity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerSeedCtx returns a context carrying a fixture user's identity,
|
||||||
|
// for seeding rows OWNED by that user. Collections RLS (2026-10-31):
|
||||||
|
// the write predicates want actor == owner for non-system rows (the
|
||||||
|
// admin write-escape covers SYSTEM collections only), and reads under
|
||||||
|
// the pool session's SeedIdentity see nothing.
|
||||||
|
func ownerSeedCtx(userID pgtype.UUID) context.Context {
|
||||||
|
return rls.WithUser(context.Background(), userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeExecAs is adminScopeExec's generic form: raw SQL under ANY
|
||||||
|
// declared identity. Use it (with the row's OWNER) for fixture writes
|
||||||
|
// to non-system collections — see ownerSeedCtx.
|
||||||
|
func scopeExecAs(t *testing.T, setup *TestServerSetup, userID pgtype.UUID, sql string, args ...any) {
|
||||||
|
adminScopeExec(t, setup, userID, sql, args...)
|
||||||
|
}
|
||||||
|
|
||||||
// seedExec runs a raw statement against the pool under the seed
|
// seedExec runs a raw statement against the pool under the seed
|
||||||
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
|
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
|
||||||
// the GUC), so governed tables need the identity declared manually
|
// the GUC), so governed tables need the identity declared manually
|
||||||
@@ -356,7 +409,12 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
|||||||
|
|
||||||
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
||||||
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
||||||
ctx := context.Background()
|
// Owner-scoped seeding: the collections RLS policies (2026-10-31)
|
||||||
|
// need an acting identity, and the harness seeds before any JWT —
|
||||||
|
// the same bootstrap fix as CreateDefaultCollectionsForUser in
|
||||||
|
// internal/handlers/auth.go. Owner == actor, so SELECT sees the
|
||||||
|
// rows, the INSERT passes, and the heal-UPDATE passes.
|
||||||
|
ctx := rls.WithUser(context.Background(), userID)
|
||||||
// Canonical Title-case names — the same list the registration seed
|
// Canonical Title-case names — the same list the registration seed
|
||||||
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
||||||
// A stale kebab-case copy lived here once and duplicated every
|
// A stale kebab-case copy lived here once and duplicated every
|
||||||
@@ -460,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")
|
||||||
|
|
||||||
@@ -596,10 +661,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
worker := services.NewWorker(3, connManager)
|
worker := services.NewWorker(3, connManager)
|
||||||
services.WorkerInstance = worker
|
services.WorkerInstance = worker
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
// One composed-engine service for search AND the collection
|
||||||
|
// subsystem (item 3.2 — mirrors main.go).
|
||||||
|
collectionSearchService := services.NewSearchService(queries, scopedDB)
|
||||||
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries, collectionSearchService)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
|
||||||
statisticsService := services.NewStatisticsService(queries)
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
@@ -769,11 +837,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
// run's creates die on UNIQUE(user_id, name) — the "collections
|
// run's creates die on UNIQUE(user_id, name) — the "collections
|
||||||
// 500-panic" rot. System collections are skipped (permanent rows).
|
// 500-panic" rot. System collections are skipped (permanent rows).
|
||||||
// Same convention as the library pass: names containing "test".
|
// Same convention as the library pass: names containing "test".
|
||||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
// Swept under the OWNER's identity: collections RLS (2026-10-31)
|
||||||
|
// makes unscoped reads return nothing, which would silently turn
|
||||||
|
// this cleanup into a no-op.
|
||||||
|
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
if cerr == nil {
|
if cerr == nil {
|
||||||
for _, coll := range adminColls {
|
for _, coll := range adminColls {
|
||||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||||
queries.DeleteCollection(ctx, coll.ID)
|
queries.DeleteCollection(adminScope, coll.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -781,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)
|
||||||
@@ -847,11 +927,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
// Same pass for test-created COLLECTIONS (the "collections
|
// Same pass for test-created COLLECTIONS (the "collections
|
||||||
// 500-panic" rot: they lingered forever and poisoned later runs).
|
// 500-panic" rot: they lingered forever and poisoned later runs).
|
||||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
// Owner-scoped for the same RLS reason as the setup sweep.
|
||||||
|
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
if cerr == nil {
|
if cerr == nil {
|
||||||
for _, coll := range adminColls {
|
for _, coll := range adminColls {
|
||||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||||
queries.DeleteCollection(ctx, coll.ID)
|
queries.DeleteCollection(adminScope, coll.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+160
-5
@@ -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(),
|
||||||
@@ -102,7 +110,9 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
|
|||||||
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
||||||
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'),
|
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'),
|
||||||
-- podcasts
|
-- podcasts
|
||||||
('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts')
|
('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts'),
|
||||||
|
('podcast_download_concurrency', '4', 'Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)', 'int', '1', '16', FALSE, 'podcasts'),
|
||||||
|
('podcast_download_speed_mbps', '0', 'Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)', 'float', '0', '10000', FALSE, 'podcasts')
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
-- Create refresh_tokens table
|
-- Create refresh_tokens table
|
||||||
@@ -1339,7 +1349,9 @@ CREATE TABLE IF NOT EXISTS collections (
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
color VARCHAR(7),
|
color VARCHAR(7),
|
||||||
icon VARCHAR(50),
|
icon VARCHAR(50),
|
||||||
auto_assign_rules JSONB,
|
-- The unified filter tree (plan item 3.2): when present the qf
|
||||||
|
-- engine drives membership; see FilterTree. NULL = static.
|
||||||
|
filter_tree JSONB,
|
||||||
view_settings JSONB,
|
view_settings JSONB,
|
||||||
show_on_dashboard BOOLEAN DEFAULT false,
|
show_on_dashboard BOOLEAN DEFAULT false,
|
||||||
query_type TEXT DEFAULT 'filter',
|
query_type TEXT DEFAULT 'filter',
|
||||||
@@ -1355,12 +1367,23 @@ CREATE TABLE IF NOT EXISTS collections (
|
|||||||
-- the row id (and everything referencing it) never churn.
|
-- the row id (and everything referencing it) never churn.
|
||||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Legacy rules-array columns RETIRED (2026-10-31): the unified filter
|
||||||
|
-- tree is the only smart-membership storage. No production row ever
|
||||||
|
-- carried a rules array or a non-default match mode (verified), so the
|
||||||
|
-- columns drop clean; the DROPs are the idempotent heal for databases
|
||||||
|
-- created before this change.
|
||||||
|
ALTER TABLE collections DROP COLUMN IF EXISTS auto_assign_rules;
|
||||||
|
ALTER TABLE collections DROP COLUMN IF EXISTS rule_match_mode;
|
||||||
|
|
||||||
|
-- Smart collections in the UNIFIED filter engine (plan item 3.2): the
|
||||||
|
-- qf tree (same DSL as saved filters, See FilterTree). NULL = static.
|
||||||
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
||||||
|
|
||||||
-- Index for dashboard queries
|
-- Index for dashboard queries
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
|
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
|
||||||
WHERE show_on_dashboard = true;
|
WHERE show_on_dashboard = true;
|
||||||
|
|
||||||
-- Create user_dashboard_preferences table
|
-- Create user_dashboard_preferences table
|
||||||
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -1626,12 +1649,19 @@ CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);
|
|||||||
CREATE TABLE IF NOT EXISTS reader_settings (
|
CREATE TABLE IF NOT EXISTS reader_settings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
setting_key VARCHAR(50) NOT NULL,
|
setting_key VARCHAR(120) NOT NULL,
|
||||||
setting_value JSONB NOT NULL,
|
setting_value JSONB NOT NULL,
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, setting_key)
|
UNIQUE(user_id, setting_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Key-width headroom (podcast plan §10 R5, item 2.1): the composite
|
||||||
|
-- `podcast_order:<uuid>` key sits at exactly 50 chars, and future
|
||||||
|
-- per-feed/per-collection preference keys must not be forced to
|
||||||
|
-- payload-stuff by a column width. Widening a varchar is metadata-only
|
||||||
|
-- (no table rewrite); idempotent on every boot.
|
||||||
|
ALTER TABLE reader_settings ALTER COLUMN setting_key TYPE VARCHAR(120);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
|
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
|
||||||
|
|
||||||
-- PDF bookmarks (custom user bookmarks)
|
-- PDF bookmarks (custom user bookmarks)
|
||||||
@@ -1989,7 +2019,16 @@ ALTER TABLE media_items
|
|||||||
-- list renders this inline (D8/D19) — never Processing Issues.
|
-- list renders this inline (D8/D19) — never Processing Issues.
|
||||||
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
|
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
ADD COLUMN IF NOT EXISTS download_error TEXT,
|
ADD COLUMN IF NOT EXISTS download_error TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0;
|
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0,
|
||||||
|
-- Monotonic queue ticket (Postgres SEQUENCE nextval at enqueue
|
||||||
|
-- time): the download pool claims in ticket order, so the enqueue
|
||||||
|
-- loop's ordering IS the download order (effective listening order
|
||||||
|
-- — §0 items 4/5). NULL for rows never queued.
|
||||||
|
ADD COLUMN IF NOT EXISTS download_queue_pos BIGINT;
|
||||||
|
|
||||||
|
-- Queue ticket source: atomic across concurrent enqueues (per-feed
|
||||||
|
-- locks serialize within a feed; the sequence covers across feeds).
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS podcast_queue_seq;
|
||||||
|
|
||||||
-- Identity + ordering + worker-worklist indexes (all partial: scan
|
-- Identity + ordering + worker-worklist indexes (all partial: scan
|
||||||
-- rows never touch these columns).
|
-- rows never touch these columns).
|
||||||
@@ -1999,6 +2038,8 @@ CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos
|
|||||||
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
|
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
|
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
|
||||||
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
|
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_items_queue
|
||||||
|
ON media_items(download_queue_pos) WHERE source = 'podcast_episode' AND download_state = 'queued';
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -2141,3 +2182,117 @@ CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
|
|||||||
-- policy's lookup table, and a user must always see their own
|
-- policy's lookup table, and a user must always see their own
|
||||||
-- subscription rows (subscribe -> visibility is a single INSERT).
|
-- subscription rows (subscribe -> visibility is a single INSERT).
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
-- ── collections + collection_items (2026-10-31): ownership at the DB ──
|
||||||
|
-- layer, same doctrine as media_items/podcast_feeds above. The
|
||||||
|
-- predicates live in FUNCTIONS (not inline) so a future sharing model
|
||||||
|
-- edits exactly one place: add the share-table EXISTS branch inside
|
||||||
|
-- app_can_read_collection / app_can_write_collection. The functions are
|
||||||
|
-- PURE over their arguments — they never query collections — so the
|
||||||
|
-- policies cannot recurse, FORCE RLS safe. Reads are authenticated-
|
||||||
|
-- allow (household-readable: any signed-in identity, SeedIdentity
|
||||||
|
-- harness included); WRITES are owner-or-admin — that is the
|
||||||
|
-- enforcement of record for Update/Delete/AddBooks, which have no
|
||||||
|
-- app-level gate.
|
||||||
|
-- ===========================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION app_is_admin() RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM users u
|
||||||
|
WHERE u.id = NULLIF(current_setting('app.user_id', true), '')::uuid
|
||||||
|
AND u.role = 'admin'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION app_can_read_collection(
|
||||||
|
p_collection_id UUID, p_owner UUID
|
||||||
|
) RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||||
|
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
|
||||||
|
AND (
|
||||||
|
p_owner = current_setting('app.user_id', true)::uuid
|
||||||
|
OR app_is_admin()
|
||||||
|
-- FUTURE SHARING slot: OR EXISTS (
|
||||||
|
-- SELECT 1 FROM collection_shares cs
|
||||||
|
-- WHERE cs.collection_id = p_collection_id
|
||||||
|
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION app_can_write_collection(
|
||||||
|
p_collection_id UUID, p_owner UUID, p_system BOOLEAN
|
||||||
|
) RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||||
|
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
|
||||||
|
AND (
|
||||||
|
p_owner = current_setting('app.user_id', true)::uuid
|
||||||
|
OR (COALESCE(p_system, false) AND app_is_admin())
|
||||||
|
-- FUTURE SHARING slot: OR EXISTS (
|
||||||
|
-- SELECT 1 FROM collection_shares cs
|
||||||
|
-- WHERE cs.collection_id = p_collection_id
|
||||||
|
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid
|
||||||
|
-- AND cs.permission IN ('edit'))
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE collections FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS p_collections_select ON collections;
|
||||||
|
CREATE POLICY p_collections_select ON collections
|
||||||
|
FOR SELECT USING (app_can_read_collection(id, user_id));
|
||||||
|
DROP POLICY IF EXISTS p_collections_insert ON collections;
|
||||||
|
CREATE POLICY p_collections_insert ON collections
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
user_id = NULLIF(current_setting('app.user_id', true), '')::uuid
|
||||||
|
OR app_is_admin()
|
||||||
|
);
|
||||||
|
DROP POLICY IF EXISTS p_collections_update ON collections;
|
||||||
|
CREATE POLICY p_collections_update ON collections
|
||||||
|
FOR UPDATE
|
||||||
|
USING (app_can_write_collection(id, user_id, is_system_collection))
|
||||||
|
WITH CHECK (app_can_write_collection(id, user_id, is_system_collection));
|
||||||
|
DROP POLICY IF EXISTS p_collections_delete ON collections;
|
||||||
|
CREATE POLICY p_collections_delete ON collections
|
||||||
|
FOR DELETE USING (app_can_write_collection(id, user_id, is_system_collection));
|
||||||
|
|
||||||
|
ALTER TABLE collection_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE collection_items FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Items ride their PARENT's accessibility: the collections SELECT
|
||||||
|
-- policy auto-applies inside these EXISTS subqueries.
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_select ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_select ON collection_items
|
||||||
|
FOR SELECT USING (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
));
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_insert ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_insert ON collection_items
|
||||||
|
FOR INSERT WITH CHECK (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
));
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_update ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_update ON collection_items
|
||||||
|
FOR UPDATE
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
))
|
||||||
|
WITH CHECK (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
));
|
||||||
|
DROP POLICY IF EXISTS p_collection_items_delete ON collection_items;
|
||||||
|
CREATE POLICY p_collection_items_delete ON collection_items
|
||||||
|
FOR DELETE USING (EXISTS (
|
||||||
|
SELECT 1 FROM collections c
|
||||||
|
WHERE c.id = collection_items.collection_id
|
||||||
|
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||||
|
));
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -459,3 +459,311 @@ resolved unscoped map:
|
|||||||
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
|
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
|
||||||
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
|
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
|
||||||
Nothing removed, nothing renamed.
|
Nothing removed, nothing renamed.
|
||||||
|
|
||||||
|
## 2026-10-30 — download_queue_pos joins media_items (additive widening)
|
||||||
|
|
||||||
|
§0 item 4 of podcast-integration.md: the download pool's monotonic
|
||||||
|
queue ticket. `download_queue_pos BIGINT` (NULL for scan rows and
|
||||||
|
never-queued episodes) is stamped from the `podcast_queue_seq` SEQUENCE
|
||||||
|
at enqueue time so claim order = enqueue order (the effective listening
|
||||||
|
order). Raw-row pins (TestContractPin_MediaListBothBranches →
|
||||||
|
rawRowKeys) whitelist the key. Nothing removed, nothing renamed.
|
||||||
|
|
||||||
|
## 2026-10-30 — episode collections (additive widening, plan item 2/2.3/2.4)
|
||||||
|
|
||||||
|
Two shapes widen, both ADDITIVE — nothing removed, nothing renamed:
|
||||||
|
|
||||||
|
- **Collections envelope** (`GET /api/collections/:id` → envelopeKeys)
|
||||||
|
gains `rule_match_mode` (smart-collection match semantics, 'any'
|
||||||
|
legacy default | 'all'). Static collections emit the default value.
|
||||||
|
- **BookInfo** (every card surface → bookInfoKeys) gains four STABLE
|
||||||
|
keys — `is_episode`, `feed_title`, `download_state`, `hidden` — the
|
||||||
|
episode-collections extras. Adapters that predate episode awareness
|
||||||
|
emit their zero values (`false`/`""`), matching the
|
||||||
|
viewer_percentage precedent (nil-representable, key always present).
|
||||||
|
|
||||||
|
## 2026-10-30 — collections envelope gains filter_tree (additive widening, item 3.2)
|
||||||
|
|
||||||
|
`GET /api/collections/:id` adds `filter_tree` to the envelope: the
|
||||||
|
unified filter tree when the smart collection carries one (item 3.2 —
|
||||||
|
tree-driven membership), `null` for legacy/static collections. The
|
||||||
|
tree is the same qf DSL as saved filters (`{scope?, mode, children}`).
|
||||||
|
Nothing removed, nothing renamed.
|
||||||
|
|
||||||
|
## 2026-10-30 — rules endpoints RETIRED (filter-system unification, item 3.4)
|
||||||
|
|
||||||
|
The per-rule CRUD endpoints and the preview endpoint are DELETED — the
|
||||||
|
unified filter tree (item 3.2) is the only smart-membership authoring
|
||||||
|
surface, and the rules PAGE is now the filter builder in the
|
||||||
|
collection's scope:
|
||||||
|
|
||||||
|
- `POST /api/collections/:id/rules`, `PUT/DELETE
|
||||||
|
/api/collections/:id/rules/:ruleId` — gone (legacy rule arrays still
|
||||||
|
EVALUATE until the collection is rewritten; conversions run at the
|
||||||
|
builder's first Apply via RulesToFilterTree).
|
||||||
|
- `POST /api/collections/test-rules` — gone (the builder's live count
|
||||||
|
via /api/media-items/filter-count replaces the preview).
|
||||||
|
- `POST /api/collections/preview` STAYS (the dashboard
|
||||||
|
custom-section-builder still consumes it — flagged for its own
|
||||||
|
migration, not silently broken).
|
||||||
|
- Legacy compatibility KEPT: `auto_assign_rules` still round-trips in
|
||||||
|
the collections envelope; `PUT /api/collections/:id/match-mode`
|
||||||
|
still serves rules-array collections.
|
||||||
|
|
||||||
|
Test-side deletions follow the asymmetry rule: subtests exercising the
|
||||||
|
deleted endpoints are gone; their surviving SEMANTICS are pinned in
|
||||||
|
their tree form (TestSmartCollections rewritten on trees,
|
||||||
|
TestEpisodeCollectionsFilterTree, TestPodcastSmartCollections).
|
||||||
|
|
||||||
|
## 2026-10-31 — collections RLS: cross-user detail/writes tighten to 404/no-op
|
||||||
|
|
||||||
|
`collections` and `collection_items` joined the RLS-governed tables
|
||||||
|
(policies `p_collections_*` / `p_collection_items_*`, predicates
|
||||||
|
`app_can_read_collection` / `app_can_write_collection` — the same
|
||||||
|
doctrine as the media_items family lens, 2026-10-29). Contract
|
||||||
|
changes:
|
||||||
|
|
||||||
|
- `GET /api/collections/:id` for another user's collection: was 200
|
||||||
|
(by-id readability), now 404 (row invisible).
|
||||||
|
- `PUT /api/collections/:id` cross-user: was ungated success (no
|
||||||
|
app-level ownership check), now 404 — the RLS update touches no
|
||||||
|
rows, and the handler maps ErrNoRows to 404.
|
||||||
|
- `POST /api/collections/:id/books` cross-user: was ungated, now 404
|
||||||
|
(rejectSmartMutation's GetCollectionData is RLS-invisible).
|
||||||
|
- `DELETE /api/collections/:id` cross-user: still 204, but now a true
|
||||||
|
DB-level no-op (zero rows deleted) instead of an app-level free hit.
|
||||||
|
- `PUT /api/collections/:id/view-settings` cross-user: 404 (the owner
|
||||||
|
gate stays as defense-in-depth; the DB is enforcement of record).
|
||||||
|
- `PUT .../match-mode` and `.../items/order` keep their app-level 403
|
||||||
|
gates; the DB now backs them.
|
||||||
|
- Reads: owner or admin (policy escape); non-owner non-admin users see
|
||||||
|
nothing, anonymous included. Future sharing rides the predicate
|
||||||
|
functions — one EXISTS branch in app_can_read/write_collection, no
|
||||||
|
policy migration.
|
||||||
|
|
||||||
|
Test-side: TestCollectionsRLS pins the cross-user matrix end-to-end;
|
||||||
|
fixture writes to non-system collections run as the row's OWNER
|
||||||
|
(scopeExecAs/ownerSeedCtx) because the admin write-escape covers
|
||||||
|
system collections only.
|
||||||
|
|
||||||
|
## 2026-10-31 — legacy collection rules surface RETIRED (tree-only storage)
|
||||||
|
|
||||||
|
The pre-tree rules machinery is gone — no production row ever carried a
|
||||||
|
rules array or a non-default match mode (verified in the dev DB), the
|
||||||
|
web never reads the envelope keys, and the Android app consumes none of
|
||||||
|
it. Deletions:
|
||||||
|
|
||||||
|
- `PUT /api/collections/:id/match-mode` — endpoint retired (it had no
|
||||||
|
doc page). The tree's root `mode` owns any|all semantics now.
|
||||||
|
- Envelope: `auto_assign_rules` and `rule_match_mode` are REMOVED from
|
||||||
|
the create/list/detail/update payloads. This entry retracts the
|
||||||
|
2026-10-30 pins that listed them. `collection_type` and `is_smart`
|
||||||
|
stay.
|
||||||
|
- Request: `auto_assign_rules`/`rule_match_mode` are no longer accepted
|
||||||
|
on create/update. A create carrying `auto_assign_rules` answers 400
|
||||||
|
naming the retirement — the custom-section builder's legacy save
|
||||||
|
(already failing to bind, verified live) fails loudly instead of
|
||||||
|
silently creating a hollow static section.
|
||||||
|
- Storage: the `collections.auto_assign_rules` and
|
||||||
|
`collections.rule_match_mode` columns are DROPPED (idempotent
|
||||||
|
`DROP COLUMN IF EXISTS` in schema.sql).
|
||||||
|
- Service: the rules CRUD layer (AddRule/SetRuleEnabled/RemoveRule +
|
||||||
|
validateRule + the UpdateCollectionRulesJSON query),
|
||||||
|
ParseRules/EnabledRules/IsSmartCollection, RulesToFilterTree (+ the
|
||||||
|
SSR preload branch), and the legacy branch of EvaluateSmartMembership
|
||||||
|
are deleted. IsCollectionSmart = tree presence.
|
||||||
|
|
||||||
|
KEPT (live consumers): `POST /api/collections/preview` still accepts
|
||||||
|
`rules` and evaluates them in memory (the custom-section builder's
|
||||||
|
preview button); `include_auto` on the list stays (the Android app
|
||||||
|
sends it). The builder's SAVE remains the flagged §12 tree migration.
|
||||||
|
|
||||||
|
## 2026-10-31 — custom-section builder save: the flagged tree migration lands
|
||||||
|
|
||||||
|
Supersedes the same-day retirement entry's loud-400 stance for the
|
||||||
|
builder's payload. `POST /api/collections` now ACCEPTS the builder's
|
||||||
|
shape and adapts it at write time:
|
||||||
|
|
||||||
|
- `auto_assign_rules` (JSON-encoded array — string or bare) converts to
|
||||||
|
a filter tree via the restored `RulesToFilterTree`; converted trees
|
||||||
|
validate under `ParseFilterTree`, so an unhonorable rule is a 400 at
|
||||||
|
write naming the problem.
|
||||||
|
- `match_type` seeds the tree's root mode (`any` default).
|
||||||
|
- `manual_book_ids` attach to the created collection after create
|
||||||
|
(unparsable ids skipped — the AddBooks leniency).
|
||||||
|
- `show_on_dashboard` persists (new create field).
|
||||||
|
- `library_id` remains selection context only (not stored).
|
||||||
|
|
||||||
|
Dashboard sections: smart user sections evaluate their tree LIVE —
|
||||||
|
`GetUserCollectionItems` merges stored membership with the composed
|
||||||
|
engine's matches (deduped, library-scoped, capped at the section
|
||||||
|
limit). This exposed and fixed a real wiring bug: every production
|
||||||
|
`NewDashboardService` call omitted the search service, so tree
|
||||||
|
evaluation through the dashboard could never work (the test harness
|
||||||
|
wired it correctly — the gap was production-only). The dashboard
|
||||||
|
handler and both inline sites now receive it, and evaluation failures
|
||||||
|
log instead of silently emptying a section.
|
||||||
|
|
||||||
|
## 2026-11-01 — feed operator vocabulary collapsed (Option A): one name per semantic
|
||||||
|
|
||||||
|
The feed field's menu offered `is`/`isn't` alongside `in`/`isn't in` —
|
||||||
|
exact synonyms (the clause compiles both pairs to the same `ANY`). The
|
||||||
|
MENUS now offer `in`/`isn't in` (+ has/missing) only. This is an
|
||||||
|
offered-vocabulary narrowing, not a parse change:
|
||||||
|
|
||||||
|
- Stored trees and saved filters carrying `feed is X` / `feed is_not X`
|
||||||
|
PARSE AND EVALUATE FOREVER (`legacyFieldOps` accepts the synonyms at
|
||||||
|
validation — the stored wire is never invalidated).
|
||||||
|
- The fields payload (the client's operator menus) no longer carries
|
||||||
|
the duplicates.
|
||||||
|
- Client id-cells are uniform: every operator on an id-valued field
|
||||||
|
accepts a set, so all id cells say "pick one or more" and accumulate
|
||||||
|
picks — no per-operator placeholder/pick distinction to explain.
|
||||||
|
|
||||||
|
## 2026-11-01 — id labels resolve at render (SSR embed): refresh keeps names
|
||||||
|
|
||||||
|
Pages that render a filter tree embed the tree's id→name map at render
|
||||||
|
time (`data-id-labels` on the builder mount) — the collection editor
|
||||||
|
(stored tree) and the bookshelf (URL `qf`). Before this, refresh showed
|
||||||
|
raw uuids: only ids persist in trees; the client's id→label cache dies
|
||||||
|
with the page, and the quiet suggest fetch reused the MEDIA-CONSTRAINED
|
||||||
|
facet, so an id whose media doesn't match the tree's sibling rules got
|
||||||
|
no row and no label (live repro: `feed in [A,B,C] AND title contains
|
||||||
|
Chapter` labeled one feed of three — two feeds had no "Chapter"
|
||||||
|
episodes).
|
||||||
|
|
||||||
|
- `SearchService.FilterTreeLabels` reads the ENTITY tables (no media
|
||||||
|
join, no tree constraint): podcast_feeds and collections are
|
||||||
|
RLS-governed through the scoped handle (invisible ids resolve to
|
||||||
|
nothing); libraries mirror the facet's visibility join; added_by is a
|
||||||
|
plain username lookup. A zero-episode feed resolves its title —
|
||||||
|
pinned (TestRulesPageEmbedsIdLabels), as is the hidden-library
|
||||||
|
non-resolution.
|
||||||
|
- The bookshelf drawer also HYDRATES at component init now (no
|
||||||
|
data-tree mount → read the form): the parent's x-init
|
||||||
|
(initBookshelf) runs BEFORE the nested builder component exists
|
||||||
|
(Alpine initializes parents first), so its builderData() call was
|
||||||
|
null and a URL-qf refresh left the drawer with an empty seeded rule
|
||||||
|
while the grid stayed filtered. Init-order contract pinned in
|
||||||
|
filter_builder.test.ts.
|
||||||
|
- The quiet suggest fetch remains only as a fallback for trees the SSR
|
||||||
|
never saw (client-side apply flows); embedded labels make it a no-op
|
||||||
|
on refresh.
|
||||||
|
|
||||||
|
## 2026-11-01 — unset podcast order defaults to reversed (audiobook-first)
|
||||||
|
|
||||||
|
The per-feed listening order (the D5 `podcast_order:<feedID>` KV shown
|
||||||
|
in the Order dropdown) resolved its UNSET state to "native" — feed
|
||||||
|
document order, which runs newest-first for classic RSS, so an
|
||||||
|
audiobook feed played its LAST chapter first. The unset default is now
|
||||||
|
`reverse` at every resolution site of that one setting: the podcast
|
||||||
|
page list + dropdown (`podcastOrderForFeed`), the player's feed chain
|
||||||
|
(reader.go), and the download worker (`orderOverrideFor`).
|
||||||
|
|
||||||
|
- Stored overrides are untouched: anyone who picked an order keeps it
|
||||||
|
(native remains a valid, savable choice).
|
||||||
|
- No ordering semantics changed — `OrderEpisodes` and the four-option
|
||||||
|
vocabulary are as they were; only the unset fallback flipped.
|
||||||
|
- Pinned: the player sequence config carries `"order":"reverse"` and
|
||||||
|
feed position 1 plays LAST for an unset feed; the podcast page's
|
||||||
|
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
|
||||||
|
replacing the former native pin).
|
||||||
|
|
||||||
|
## 2026-11-01 — new podcast smart collections are BORN reversed
|
||||||
|
|
||||||
|
`POST /api/collections` with an EPISODES-scope `filter_tree` seeds
|
||||||
|
`view_settings.chain_order = "reverse"` on the new row (merge semantics:
|
||||||
|
an explicit client `chain_order` is never clobbered). The dropdown's
|
||||||
|
default selection and the chain/page behavior come from the STORED
|
||||||
|
value — no resolution-logic changes; books-scope smart collections and
|
||||||
|
statics get nothing seeded; existing collections are untouched.
|
||||||
|
|
||||||
|
Pinned (TestSmartCollectionBornReversed): episodes seed, explicit-keep,
|
||||||
|
books-scope no-seed, static no-seed; TestEpisodeCollectionsFilterTree's
|
||||||
|
born-reversed chain/page pins replace the former native expectations
|
||||||
|
(which changelog here per the expectation-change rule). One misplaced
|
||||||
|
pin from 81e54f89 was corrected while flipping: the API envelope's
|
||||||
|
match list is NOT chain-ordered and never was — order pins live on the
|
||||||
|
SSR page and the reader chain.
|
||||||
|
|
||||||
|
## 2026-11-01 — label endpoint replaces the suggest-facet fallback
|
||||||
|
|
||||||
|
`GET /api/filter/labels?field=X&ids=a,b,c` names id-kind filter values
|
||||||
|
from their source tables (entity rows only — no media join, no tree
|
||||||
|
constraint; visibility/RLS ride the request identity). Non-id fields
|
||||||
|
are a 400; ids truncate at the same 100 cap the SSR embed uses.
|
||||||
|
|
||||||
|
The client's label resolution (`resolveIdLabels`/`resolveLabelsAsync`)
|
||||||
|
now uses ONLY this endpoint. The former fallback — the quiet suggest
|
||||||
|
fetch through the MEDIA-CONSTRAINED facet — is deleted: it labeled only
|
||||||
|
the ids whose media matched the tree's sibling rules (the live feed
|
||||||
|
repro), which mattered exactly where the SSR embed can't reach:
|
||||||
|
applying a SAVED filter from a cold bookshelf. The apply flow now
|
||||||
|
resolves through the endpoint and rebuilds the chips bar when labels
|
||||||
|
land. Pinned (TestFilterLabelsEndpoint): a zero-episode feed resolves
|
||||||
|
its title, comma-joined ids resolve together, an unsubscribed feed id
|
||||||
|
stays unnamed (RLS), non-id fields are 400.
|
||||||
|
|
||||||
|
## 2026-11-01 — the rules-Apply path seeds the reversed default too
|
||||||
|
|
||||||
|
PUT /api/collections/:id carrying an EPISODES-scope filter_tree seeds
|
||||||
|
`chain_order = "reverse"` when neither the request nor the stored
|
||||||
|
view_settings names one — the creation seed's twin: a static collection
|
||||||
|
becoming smart lands with the same default a born-smart one gets.
|
||||||
|
Explicit values (client-sent or stored — e.g. a deliberate native)
|
||||||
|
are never clobbered. Pinned (TestSmartCollectionBornReversed):
|
||||||
|
apply-seeds-when-unordered, respects stored native, respects client
|
||||||
|
native.
|
||||||
|
|
||||||
|
TestPodcastSmartCollections' acceptance pin updated with the same flip:
|
||||||
|
the briefing acceptance now starts from the page's FIRST card (feed
|
||||||
|
position 5 — the born-reversed default's book order) and chains into
|
||||||
|
feed position 1. The acceptance meaning is unchanged: play from what
|
||||||
|
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()"
|
|
||||||
style="display: none;">
|
|
||||||
|
|
||||||
<!-- Search with HTMX -->
|
The server returns HTML (checked checkboxes included); the store tracks
|
||||||
<input @input.debounce.300ms="searchBooks($el.value)"
|
selection across swaps; nobody rebuilds markup from JSON.
|
||||||
hx-get="/api/media-items/filtered"
|
|
||||||
hx-target="#book-picker-grid"
|
|
||||||
hx-include="#filter-form" />
|
|
||||||
|
|
||||||
<!-- Book grid with checkboxes -->
|
## Local state: plain `x-data` on the smallest element
|
||||||
<div id="book-picker-grid" x-init="loadBooks()">
|
|
||||||
<!-- Server returns HTML with checkboxes -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected count -->
|
Ephemeral UI (a dropdown, a panel toggle) needs no store and no
|
||||||
<span x-text="$store.bookPicker.selectedCount"></span>
|
round-trip:
|
||||||
|
|
||||||
<!-- Submit -->
|
```html
|
||||||
<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
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
---
|
|
||||||
method: POST
|
|
||||||
path: /api/collections/:id/rules
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:877 CollectionHandler.AddRule -->
|
|
||||||
|
|
||||||
# Add Auto-Assign Rule
|
|
||||||
|
|
||||||
Add an automatic book assignment rule to a collection.
|
|
||||||
|
|
||||||
**Endpoint**: `POST /api/collections/{id}/rules`
|
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ------------- | -------- | --------------- |
|
|
||||||
| id | string (UUID) | Yes | Collection UUID |
|
|
||||||
|
|
||||||
## Request Body
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
|
|
||||||
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
|
|
||||||
| value | string/number | Yes | Value to compare against |
|
|
||||||
| priority | integer | No | Rule priority (1 = highest, default: 1) |
|
|
||||||
| enabled | boolean | No | Whether rule is active (default: true) |
|
|
||||||
|
|
||||||
### Supported Fields
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| -------------- | ------ | ------------------------------------- |
|
|
||||||
| genre | string | Book genre |
|
|
||||||
| author | string | Book author |
|
|
||||||
| series | string | Book series name |
|
|
||||||
| language | string | Book language |
|
|
||||||
| publisher | string | Publisher name |
|
|
||||||
| copyright_year | number | Publication year (numeric comparison) |
|
|
||||||
| tags | string | Book tags |
|
|
||||||
|
|
||||||
### Supported Operators
|
|
||||||
|
|
||||||
| Operator | Type | Description |
|
|
||||||
| ------------ | ------ | ------------------------------------- |
|
|
||||||
| equals | all | Exact match |
|
|
||||||
| not_equals | all | Not equal |
|
|
||||||
| contains | string | Contains substring (case-insensitive) |
|
|
||||||
| not_contains | string | Does not contain |
|
|
||||||
| starts_with | string | Starts with (case-insensitive) |
|
|
||||||
| ends_with | string | Ends with (case-insensitive) |
|
|
||||||
| greater_than | number | Greater than |
|
|
||||||
| less_than | number | Less than |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```json example-request
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
"priority": 1,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (201 Created)
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"collection_id": null,
|
|
||||||
"is_smart": null,
|
|
||||||
"rule": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 400 | Invalid request (validation failed) |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 404 | Collection not found |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
@@ -23,7 +23,6 @@ Creating a collection whose name already exists for the user returns **409**.
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -39,7 +38,6 @@ Creating a collection whose name already exists for the user returns **409**.
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } },
|
"view_settings": { "kobo": { "view_mode": "grid" } },
|
||||||
"book_count": 0,
|
"book_count": 0,
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
||||||
@@ -57,20 +55,13 @@ RFC3339. Treat it as display text; do not parse it as an ISO timestamp.
|
|||||||
| description | string | no | |
|
| description | string | no | |
|
||||||
| color | string | no | hex color, e.g. `"#FF5733"` |
|
| color | string | no | hex color, e.g. `"#FF5733"` |
|
||||||
| icon | string | no | emoji |
|
| icon | string | no | emoji |
|
||||||
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by non-empty `auto_assign_rules` |
|
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` |
|
||||||
| auto_assign_rules | array | no | rule objects, see below |
|
|
||||||
| view_settings | object | no | per-device display preferences |
|
| view_settings | object | no | per-device display preferences |
|
||||||
|
| filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart |
|
||||||
### Auto-assign rule object
|
| auto_assign_rules | string \| array | no | custom-section-builder payload — converted to `filter_tree` at write (see custom-section-builder doc) |
|
||||||
|
| match_type | string | no | builder payload: `any` (default) or `all` — seeds the converted tree's root mode |
|
||||||
| Field | Type | Required | Notes |
|
| manual_book_ids | string\[\] | no | builder payload: attached to the collection after create |
|
||||||
| ----- | ---- | -------- | ----- |
|
| show_on_dashboard | boolean | no | pins the collection as a dashboard section |
|
||||||
| id | string | no | rule id |
|
|
||||||
| field | string | yes | one of `genre`, `series`, `author`, `language`, `publisher`, `copyright_year`, `tags` |
|
|
||||||
| operator | string | yes | one of `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `greater_than`, `less_than` |
|
|
||||||
| value | string | yes | value to compare against |
|
|
||||||
| priority | number | no | 1-10; higher values take precedence |
|
|
||||||
| enabled | boolean | no | absent = enabled (rules written before this field existed keep matching) |
|
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
|
|||||||
"description": "Books I want to read soon",
|
"description": "Books I want to read soon",
|
||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } },
|
"view_settings": { "kobo": { "view_mode": "grid" } },
|
||||||
"books": [],
|
"books": [],
|
||||||
"book_count": 0,
|
"book_count": 0,
|
||||||
@@ -57,8 +56,8 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
|
|||||||
| description | string | |
|
| description | string | |
|
||||||
| color | string | |
|
| color | string | |
|
||||||
| icon | string | |
|
| icon | string | |
|
||||||
| auto_assign_rules | array \| null | |
|
|
||||||
| view_settings | object | |
|
| view_settings | object | |
|
||||||
|
| filter_tree | object \| null | the unified filter tree (qf DSL); null = static |
|
||||||
| books | array | book cards in this collection |
|
| books | array | book cards in this collection |
|
||||||
| book_count | number | length of `books` |
|
| book_count | number | length of `books` |
|
||||||
| is_smart | boolean | |
|
| is_smart | boolean | |
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ GET /api/collections?include_auto=true&sort_by=name
|
|||||||
"description": "Books I want to read soon",
|
"description": "Books I want to read soon",
|
||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
|
||||||
"book_count": 12,
|
"book_count": 12,
|
||||||
"is_smart": false
|
"is_smart": false
|
||||||
@@ -65,7 +64,6 @@ GET /api/collections?include_auto=true&sort_by=name
|
|||||||
| description | string | |
|
| description | string | |
|
||||||
| color | string | |
|
| color | string | |
|
||||||
| icon | string | |
|
| icon | string | |
|
||||||
| auto_assign_rules | array \| null | raw rules JSON; non-null implies smart |
|
|
||||||
| created_at | string | Go `time.String()` format |
|
| created_at | string | Go `time.String()` format |
|
||||||
| book_count | number | live count; respects `library_id` for smart collections |
|
| book_count | number | live count; respects `library_id` for smart collections |
|
||||||
| is_smart | boolean | |
|
| is_smart | boolean | |
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
method: DELETE
|
|
||||||
path: /api/collections/:id/rules/:ruleId
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:960 CollectionHandler.RemoveRule -->
|
|
||||||
|
|
||||||
# Remove Auto-Assign Rule
|
|
||||||
|
|
||||||
Remove an automatic book assignment rule from a collection.
|
|
||||||
|
|
||||||
**Endpoint**: `DELETE /api/collections/{collectionId}/rules/{ruleId}`
|
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| ------------ | ------------- | -------- | --------------- |
|
|
||||||
| collectionId | string (UUID) | Yes | Collection UUID |
|
|
||||||
| ruleId | string (UUID) | Yes | Rule UUID |
|
|
||||||
|
|
||||||
## Response (204 No Content)
|
|
||||||
|
|
||||||
Rule deleted successfully. No response body.
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"is_smart": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`is_smart` reflects the collection state AFTER removal — removing the
|
|
||||||
last rule converts a smart collection back to static.
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ---------------------------- |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 404 | Collection or rule not found |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
method: PUT
|
||||||
|
path: /api/collections/:id/view-settings
|
||||||
|
auth: jwt
|
||||||
|
area: collections
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/collections.go CollectionHandler.SetCollectionChainOrder -->
|
||||||
|
|
||||||
|
# Set Collection Chain Order
|
||||||
|
|
||||||
|
Sets the curated listening order for the collection's episode sequence
|
||||||
|
(plan §10 R2): the chain producer (`buildSmartCollectionSequenceContext`)
|
||||||
|
plays `Play as Sequence` in this order. The key merges into the
|
||||||
|
collection's `view_settings` JSONB — playlist toggles that share the
|
||||||
|
document survive the write. Owner-only.
|
||||||
|
|
||||||
|
## Example request
|
||||||
|
|
||||||
|
```json example-request
|
||||||
|
{
|
||||||
|
"chain_order": "newest"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example response
|
||||||
|
|
||||||
|
```json example-response
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"chain_order": "newest",
|
||||||
|
"view_settings": { "chain_order": "newest" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
| ----- | ---- | -------- | ----- |
|
||||||
|
| chain_order | string | Yes | One of `native`, `newest`, `oldest`, `reverse`; `native`/empty removes the stored key |
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Body | When |
|
||||||
|
| ------ | ---- | ---- |
|
||||||
|
| 400 | `{"error": "invalid collection id"}` | malformed id |
|
||||||
|
| 400 | `{"error": "chain_order must be one of: native, newest, oldest, reverse"}` | unknown order |
|
||||||
|
| 404 | `{"error": "collection not found"}` | unknown id |
|
||||||
|
| 401 / 403 | — | missing JWT / not the owner |
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
---
|
|
||||||
method: POST
|
|
||||||
path: /api/collections/test-rules
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:996 CollectionHandler.TestRules -->
|
|
||||||
|
|
||||||
# Test Rule
|
|
||||||
|
|
||||||
Test which books would match given rules without saving.
|
|
||||||
|
|
||||||
**Endpoint**: `POST /api/collections/test-rules`
|
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Request Body
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| ----- | ----- | -------- | ----------------------------- |
|
|
||||||
| rules | array | Yes | Array of rule objects to test |
|
|
||||||
|
|
||||||
### Rule Object
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
|
|
||||||
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
|
|
||||||
| value | string/number | Yes | Value to compare against |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```json example-request
|
|
||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (200 OK)
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"matches": [
|
|
||||||
{
|
|
||||||
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"title": "Foundation",
|
|
||||||
"author": "Isaac Asimov",
|
|
||||||
"cover_image_path": "/covers/foundation.jpg",
|
|
||||||
"match_reason": "Matched rule: genre equals Science Fiction"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 42
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Case
|
|
||||||
|
|
||||||
Test rules before creating a collection to verify correct book matching. This endpoint shows which books would be added without actually modifying any collections.
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 400 | Invalid request (validation failed) |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
@@ -24,7 +24,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
@@ -41,7 +40,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "hide_finished": true, "show_up_next": true },
|
"view_settings": { "hide_finished": true, "show_up_next": true },
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
||||||
}
|
}
|
||||||
@@ -57,8 +55,8 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
| description | string | no | |
|
| description | string | no | |
|
||||||
| color | string | no | |
|
| color | string | no | |
|
||||||
| icon | string | no | |
|
| icon | string | no | |
|
||||||
| auto_assign_rules | array | no | replaces the whole rule set (rule object shape: see Create Collection) |
|
|
||||||
| view_settings | object | no | merged into stored settings |
|
| view_settings | object | no | merged into stored settings |
|
||||||
|
| filter_tree | object | no | replaces the stored filter tree (omitting it keeps the stored one) |
|
||||||
| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings |
|
| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings |
|
||||||
| show_up_next | array | no | same shape as `hide_finished` |
|
| show_up_next | array | no | same shape as `hide_finished` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
method: PUT
|
|
||||||
path: /api/collections/:id/rules/:ruleId
|
|
||||||
auth: jwt
|
|
||||||
area: collections
|
|
||||||
---
|
|
||||||
<!-- source: internal/handlers/collections.go:922 CollectionHandler.UpdateRule (SetRuleEnabled) -->
|
|
||||||
|
|
||||||
# Update Collection Rule
|
|
||||||
|
|
||||||
Despite the route name, this endpoint **toggles a rule's enabled state only**.
|
|
||||||
It does not update the rule's field/operator/value — rules are replaced wholesale
|
|
||||||
via [Create Collection](create_collection.md) (`auto_assign_rules`) or added via
|
|
||||||
the rules endpoint.
|
|
||||||
|
|
||||||
## Example request
|
|
||||||
|
|
||||||
```json example-request
|
|
||||||
{
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example response
|
|
||||||
|
|
||||||
```json example-response
|
|
||||||
{
|
|
||||||
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"is_smart": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Request fields
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
| ----- | ---- | -------- | ----- |
|
|
||||||
| enabled | boolean | yes | toggles the rule on/off |
|
|
||||||
|
|
||||||
## Errors
|
|
||||||
|
|
||||||
| Status | Body | When |
|
|
||||||
| ------ | ---- | ---- |
|
|
||||||
| 400 | `{"error": "invalid collection id"}` / `{"error": "rule id required"}` / `{"error": "invalid request"}` | malformed |
|
|
||||||
| 404 | `{"error": "rule not found"}` | no such rule |
|
|
||||||
| 500 | `{"error": "..."}` | unexpected server error |
|
|
||||||
@@ -68,6 +68,15 @@ Creates a new custom collection with filter rules and/or manual book selection.
|
|||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
|
**Save contract (2026-10-31 tree migration):** the rules array travels
|
||||||
|
as a JSON-encoded string and the server converts it to a filter tree at
|
||||||
|
write time — `match_type` seeds the tree's root mode (`any` default),
|
||||||
|
`manual_book_ids` attach to the created collection, and
|
||||||
|
`show_on_dashboard` persists. `library_id` is selection context only
|
||||||
|
(sections scope to the dashboard's active library at render). Invalid
|
||||||
|
rules are a 400 at write naming the problem; the section renders stored
|
||||||
|
picks + the tree's live matches, deduped.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My Custom Section",
|
"name": "My Custom Section",
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
@@ -41,7 +41,8 @@ Add a folder to an existing library (Admin only).
|
|||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"library_id": "uuid",
|
"library_id": "uuid",
|
||||||
"folder_path": "/path/to/comics",
|
"folder_path": "/path/to/comics",
|
||||||
"created_at": "2026-01-31T10:00:00Z"
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
|
"purpose": "scan"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ Create a new library (Admin only).
|
|||||||
"created_at": "2026-01-31T10:00:00Z",
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
"created_by_admin_id": null,
|
"created_by_admin_id": null,
|
||||||
"library_type_id": null,
|
"library_type_id": null,
|
||||||
"updated_at": null
|
"updated_at": null,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
"created_at": null,
|
"created_at": null,
|
||||||
"created_by_admin_id": null,
|
"created_by_admin_id": null,
|
||||||
"library_type_id": null,
|
"library_type_id": null,
|
||||||
"updated_at": null
|
"updated_at": null,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ Lists the filesystem folders configured for a library.
|
|||||||
"id": "aa0e8400-e29b-41d4-a716-446655440000",
|
"id": "aa0e8400-e29b-41d4-a716-446655440000",
|
||||||
"library_id": "990e8400-e29b-41d4-a716-446655440000",
|
"library_id": "990e8400-e29b-41d4-a716-446655440000",
|
||||||
"folder_path": "/books/manga",
|
"folder_path": "/books/manga",
|
||||||
"created_at": "2026-01-01T00:00:00Z"
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"purpose": "scan"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ A top-level JSON **array** of library rows:
|
|||||||
"updated_at": "2026-01-31T10:00:00Z",
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
"type_name": "ebooks",
|
"type_name": "ebooks",
|
||||||
"type_description": "Ebook libraries",
|
"type_description": "Ebook libraries",
|
||||||
"is_visible": true
|
"is_visible": true,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ Update a library's information.
|
|||||||
"created_at": "2026-02-08T10:00:00Z",
|
"created_at": "2026-02-08T10:00:00Z",
|
||||||
"updated_at": "2026-02-08T11:00:00Z",
|
"updated_at": "2026-02-08T11:00:00Z",
|
||||||
"created_by_admin_id": null,
|
"created_by_admin_id": null,
|
||||||
"description": null
|
"description": null,
|
||||||
|
"allow_member_feeds": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,23 @@ Tags and contributors are automatically normalized:
|
|||||||
"summary": null,
|
"summary": null,
|
||||||
"total_characters": null,
|
"total_characters": null,
|
||||||
"volume": null,
|
"volume": null,
|
||||||
"web_url": null
|
"web_url": null,
|
||||||
|
"source": null,
|
||||||
|
"source_feed_id": null,
|
||||||
|
"feed_position": null,
|
||||||
|
"episode_number": null,
|
||||||
|
"season_number": null,
|
||||||
|
"episode_guid": null,
|
||||||
|
"enclosure_url": null,
|
||||||
|
"chapters_url": null,
|
||||||
|
"transcript_url": null,
|
||||||
|
"hosts": null,
|
||||||
|
"guests": null,
|
||||||
|
"declared_duration_seconds": null,
|
||||||
|
"download_state": null,
|
||||||
|
"download_attempts": null,
|
||||||
|
"download_queue_pos": null,
|
||||||
|
"download_error": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,23 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
"total_characters": null,
|
"total_characters": null,
|
||||||
"updated_at": null,
|
"updated_at": null,
|
||||||
"volume": null,
|
"volume": null,
|
||||||
"web_url": null
|
"web_url": null,
|
||||||
|
"source": null,
|
||||||
|
"source_feed_id": null,
|
||||||
|
"feed_position": null,
|
||||||
|
"episode_number": null,
|
||||||
|
"season_number": null,
|
||||||
|
"episode_guid": null,
|
||||||
|
"enclosure_url": null,
|
||||||
|
"chapters_url": null,
|
||||||
|
"transcript_url": null,
|
||||||
|
"hosts": null,
|
||||||
|
"guests": null,
|
||||||
|
"declared_duration_seconds": null,
|
||||||
|
"download_state": null,
|
||||||
|
"download_attempts": null,
|
||||||
|
"download_queue_pos": null,
|
||||||
|
"download_error": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -128,7 +128,23 @@ Update media item metadata (Admin only).
|
|||||||
"tags_search": null,
|
"tags_search": null,
|
||||||
"total_characters": null,
|
"total_characters": null,
|
||||||
"volume": null,
|
"volume": null,
|
||||||
"web_url": null
|
"web_url": null,
|
||||||
|
"source": null,
|
||||||
|
"source_feed_id": null,
|
||||||
|
"feed_position": null,
|
||||||
|
"episode_number": null,
|
||||||
|
"season_number": null,
|
||||||
|
"episode_guid": null,
|
||||||
|
"enclosure_url": null,
|
||||||
|
"chapters_url": null,
|
||||||
|
"transcript_url": null,
|
||||||
|
"hosts": null,
|
||||||
|
"guests": null,
|
||||||
|
"declared_duration_seconds": null,
|
||||||
|
"download_state": null,
|
||||||
|
"download_attempts": null,
|
||||||
|
"download_queue_pos": null,
|
||||||
|
"download_error": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user