Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
|
||||||
@@ -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,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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
+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)
|
||||||
|
|||||||
@@ -459,6 +459,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 +500,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 +565,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 +767,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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
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")
|
||||||
|
|||||||
@@ -329,6 +329,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 +372,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
|
||||||
@@ -596,10 +617,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 +793,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -847,11 +875,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
||||||
|
));
|
||||||
|
|
||||||
|
|
||||||
@@ -459,3 +459,281 @@ 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -510,9 +510,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,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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ paths:
|
|||||||
"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" } }
|
||||||
}
|
}
|
||||||
responses:
|
responses:
|
||||||
@@ -277,33 +276,6 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
"/api/collections/test-rules":
|
|
||||||
post:
|
|
||||||
tags: [collections]
|
|
||||||
summary: "Test Rule"
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
example: |-
|
|
||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
"/api/collections/{id}":
|
"/api/collections/{id}":
|
||||||
delete:
|
delete:
|
||||||
tags: [collections]
|
tags: [collections]
|
||||||
@@ -335,7 +307,6 @@ paths:
|
|||||||
"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"]
|
||||||
@@ -409,38 +380,10 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
"/api/collections/{id}/rules":
|
"/api/collections/{id}/view-settings":
|
||||||
post:
|
|
||||||
tags: [collections]
|
|
||||||
summary: "Add Auto-Assign Rule"
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
example: |-
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
"priority": 1,
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Created
|
|
||||||
"/api/collections/{id}/rules/{ruleId}":
|
|
||||||
delete:
|
|
||||||
tags: [collections]
|
|
||||||
summary: "Remove Auto-Assign Rule"
|
|
||||||
security:
|
|
||||||
- bearerAuth: []
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: No Content
|
|
||||||
put:
|
put:
|
||||||
tags: [collections]
|
tags: [collections]
|
||||||
summary: "Update Collection Rule"
|
summary: "Set Collection Chain Order"
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -448,11 +391,11 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
example: |-
|
example: |-
|
||||||
{
|
{
|
||||||
"enabled": false
|
"chain_order": "newest"
|
||||||
}
|
}
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: OK
|
||||||
"/api/conflicts":
|
"/api/conflicts":
|
||||||
get:
|
get:
|
||||||
tags: [conflicts]
|
tags: [conflicts]
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<!-- Gate-checked by TestUIControlLabels (internal/docs/ui_labels_test.go):
|
||||||
|
every `controls` entry must exist as a literal in templates/*.templ
|
||||||
|
(or web/src for source: ts). Renames fail make test. Entry text is the
|
||||||
|
canonical spelling cited across the docs. -->
|
||||||
|
|
||||||
|
# UI controls glossary
|
||||||
|
|
||||||
|
The canonical spellings for controls cited across the UI documentation. Docs
|
||||||
|
quote the label exactly as this page lists it — one entry per control; two
|
||||||
|
spellings of one control is a defect. The `controls` block is machine-read:
|
||||||
|
when a doc first names a control, add its entry in the same commit; when a
|
||||||
|
rename lands, update the entry in the same commit and the gate goes green
|
||||||
|
again.
|
||||||
|
|
||||||
|
Entry fields:
|
||||||
|
|
||||||
|
- `label` — the exact rendered text users see
|
||||||
|
- `where` — semantic path (page → section/card), never spatial; see the
|
||||||
|
[writing standard](writing-ui-docs.md)
|
||||||
|
- `does` — what interacting with it produces (the outcome)
|
||||||
|
- `source` — optional; default `templ` (`templates/*.templ`); `ts` searches `web/src`
|
||||||
|
- `also` — optional, pipe-separated extra literal fragments to search when
|
||||||
|
the visible label is Alpine-composed rather than static (OR-checked with `label`)
|
||||||
|
- `note` — optional clarifier
|
||||||
|
|
||||||
|
Form-field labels inside a single modal (Name, Description…) are cited
|
||||||
|
inline in docs and not tracked here; this list carries the controls people
|
||||||
|
navigate by.
|
||||||
|
|
||||||
|
## Dashboard, profile & podcasts
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: Customize Dashboard
|
||||||
|
where: Library bar on the dashboard (button); opens the Customize Dashboard panel
|
||||||
|
does: Opens per-library shelf arrangement — drag order, show/hide switches, items per section.
|
||||||
|
|
||||||
|
label: Save Changes
|
||||||
|
where: Customize Dashboard panel → submit
|
||||||
|
does: Saves that library's shelf arrangement.
|
||||||
|
|
||||||
|
label: Refresh
|
||||||
|
where: Library bar on the dashboard (button)
|
||||||
|
does: Re-reads libraries without a full page reload.
|
||||||
|
note: icon button with a tooltip title
|
||||||
|
|
||||||
|
label: Create Custom Section
|
||||||
|
where: the custom-section builder page (/custom-section) → page title
|
||||||
|
does: Page where a dashboard shelf is defined by filter rules or hand-picked books.
|
||||||
|
|
||||||
|
label: Update Password
|
||||||
|
where: profile page → Change Password → submit
|
||||||
|
does: Saves the new password after live requirement checks.
|
||||||
|
|
||||||
|
label: Remove My Account
|
||||||
|
where: profile page → Danger Zone
|
||||||
|
does: Deletes the account and all personal data after confirmation; blocked for the last admin.
|
||||||
|
|
||||||
|
label: Add Feed
|
||||||
|
where: Podcasts page
|
||||||
|
does: Opens the Add Podcast Feed panel.
|
||||||
|
|
||||||
|
label: Add & Subscribe
|
||||||
|
where: Add Podcast Feed panel → submit
|
||||||
|
does: Subscribes the feed and starts watching it for new episodes.
|
||||||
|
also: Add & Subscribe
|
||||||
|
note: templ source stores the ampersand entity; the rendered label reads "Add & Subscribe"
|
||||||
|
|
||||||
|
label: Download all episodes now
|
||||||
|
where: Add Podcast Feed panel
|
||||||
|
does: Grabs the feed's back catalog immediately on subscribe.
|
||||||
|
|
||||||
|
label: Player settings
|
||||||
|
where: audio player → settings control
|
||||||
|
does: Opens the per-player preferences — Autoplay on open, Default playback speed, Resume rewind.
|
||||||
|
also: title="Settings"
|
||||||
|
|
||||||
|
label: Shortcuts & interactions
|
||||||
|
where: audio player → the ? control
|
||||||
|
does: Lists every keyboard shortcut the player supports.
|
||||||
|
also: Shortcuts & interactions
|
||||||
|
note: templ source stores the ampersand entity
|
||||||
|
|
||||||
|
label: Chapters
|
||||||
|
where: audio player → chapter-list control
|
||||||
|
does: Opens the chapter list and shows the current chapter position.
|
||||||
|
note: title attribute on the control
|
||||||
|
|
||||||
|
label: Bookmarks
|
||||||
|
where: audio player → bookmarks control
|
||||||
|
does: Opens your markers for this title; tap one to jump back.
|
||||||
|
note: title attribute on the control
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: New Collection
|
||||||
|
where: Collections page
|
||||||
|
does: Opens the create-collection modal.
|
||||||
|
note: the empty page offers the same action as "Create Your First Collection"
|
||||||
|
|
||||||
|
label: Create Your First Collection
|
||||||
|
where: Collections page, when no collections exist
|
||||||
|
does: Opens the create-collection modal.
|
||||||
|
|
||||||
|
label: Create Collection
|
||||||
|
where: create-collection modal → submit
|
||||||
|
does: Saves the new collection and returns to the Collections page.
|
||||||
|
|
||||||
|
label: Update Collection
|
||||||
|
where: edit-collection modal → submit
|
||||||
|
does: Saves your changes to the collection.
|
||||||
|
|
||||||
|
label: Static
|
||||||
|
where: create-collection modal → Type
|
||||||
|
does: Chooses a hand-picked collection — you add and remove items yourself.
|
||||||
|
|
||||||
|
label: ⚡ Smart
|
||||||
|
where: create-collection modal → Type; also shown as a badge on smart collections page and detail
|
||||||
|
does: Chooses a rules-matched collection — its membership stays current by itself.
|
||||||
|
|
||||||
|
label: Rules scope
|
||||||
|
where: create-collection modal, when Smart is chosen
|
||||||
|
does: Asks what the rules ask about — the two buttons below it.
|
||||||
|
also: 📚 Books|🎧 Podcast episodes
|
||||||
|
note: "Books" and "Podcast episodes" as the two scope buttons
|
||||||
|
|
||||||
|
label: Restore System
|
||||||
|
where: Collections page
|
||||||
|
does: Opens a pick-a-shelf modal to bring back a system collection you deleted.
|
||||||
|
|
||||||
|
label: Restore
|
||||||
|
where: Restore System Collection modal → submit
|
||||||
|
does: Brings the selected system collection back.
|
||||||
|
|
||||||
|
label: Edit Rules
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens the smart-collection rules editor for this collection.
|
||||||
|
|
||||||
|
label: Filter tree
|
||||||
|
where: smart-collection rules editor
|
||||||
|
does: The area where the group and rules are built.
|
||||||
|
note: read/write rules — the how is the filter guide
|
||||||
|
|
||||||
|
label: Apply to Collection
|
||||||
|
where: smart-collection rules editor → save
|
||||||
|
does: Saves the filter tree and makes the collection match those rules from now on.
|
||||||
|
|
||||||
|
label: Add Items
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens the item picker window.
|
||||||
|
|
||||||
|
label: Add Selected
|
||||||
|
where: item picker → footer
|
||||||
|
does: Adds everything checked in the picker to the collection.
|
||||||
|
|
||||||
|
label: Remove Selected
|
||||||
|
where: collection detail page
|
||||||
|
does: Opens a confirm dialog to remove the checked items from the collection.
|
||||||
|
|
||||||
|
label: Search within collection...
|
||||||
|
where: collection detail page → search box
|
||||||
|
does: Filters the displayed items — nothing is removed.
|
||||||
|
|
||||||
|
label: Play as Sequence
|
||||||
|
where: collection detail page (when it has a downloaded episode)
|
||||||
|
does: Plays the collection's episodes one after another in the listening order you pick.
|
||||||
|
|
||||||
|
label: Hide finished books (reading-queue mode)
|
||||||
|
where: edit-collection modal
|
||||||
|
does: Finished books disappear from the collection's shelf.
|
||||||
|
|
||||||
|
label: Show Up Next strip
|
||||||
|
where: edit-collection modal
|
||||||
|
does: Adds this collection's Up Next strip to the podcast page.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Everywhere else
|
||||||
|
|
||||||
|
```controls
|
||||||
|
label: Filters
|
||||||
|
where: All Books / Podcasts page → toolbar
|
||||||
|
does: Opens the filter drawer for building and applying rules.
|
||||||
|
|
||||||
|
label: Register Device
|
||||||
|
where: Devices page
|
||||||
|
does: Submits a device registration request for the pending-approval flow.
|
||||||
|
|
||||||
|
label: Download All
|
||||||
|
where: Podcast page → episodes area
|
||||||
|
does: Queues every episode of the feed for download.
|
||||||
|
|
||||||
|
label: Download order
|
||||||
|
where: Add Podcast Feed panel; podcast feed settings
|
||||||
|
does: Sets the ordering used when feed episodes are downloaded.
|
||||||
|
|
||||||
|
label: Match ALL / Group ANY header
|
||||||
|
where: filter drawer → group header (also smart collections → Rules and dashboard sections)
|
||||||
|
does: Sets how the group combines its rules; clicking the header flips ALL/ANY.
|
||||||
|
also: ? 'Match' : 'Group'|? 'ANY' : 'ALL'
|
||||||
|
note: label is Alpine-composed — 'Match' for the whole builder, 'Group' for nested; docs write it as MATCH ALL / MATCH ANY
|
||||||
|
|
||||||
|
label: Reset to Scanned
|
||||||
|
where: book detail → edit modal → scan information area
|
||||||
|
does: Restores scanner-extracted metadata over manual edits.
|
||||||
|
|
||||||
|
label: Auto-Scan
|
||||||
|
where: Settings page → Scanning section
|
||||||
|
does: Enables automatic periodic library scanning on the interval below it.
|
||||||
|
|
||||||
|
label: Scan Interval (seconds)
|
||||||
|
where: Settings page → Scanning section
|
||||||
|
does: Sets the seconds between automatic scans.
|
||||||
|
|
||||||
|
label: Resume rewind
|
||||||
|
where: audio player → player settings; profile settings
|
||||||
|
does: Rewinds playback a configured few seconds when you resume.
|
||||||
|
also: Resume Rewind
|
||||||
|
note: profile page spells it capitalized
|
||||||
|
|
||||||
|
label: Reading Direction
|
||||||
|
where: book detail → edit modal
|
||||||
|
does: Chooses the direction pages advance (publisher convention — e.g. manga).
|
||||||
|
|
||||||
|
label: Scan Info
|
||||||
|
where: book detail page → info block
|
||||||
|
does: Displays scanner-supplied notes attached to the item.
|
||||||
|
also: Scan Information
|
||||||
|
note: the editable form label in the modal reads "Scan Information"
|
||||||
|
```
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Writing UI documentation without screenshots
|
||||||
|
|
||||||
|
Bookhoard's UI documentation uses no screenshots. This page is the standard
|
||||||
|
for writing them. The flagship example of the style is the
|
||||||
|
[filter system documentation](../user/filters/index.md): one system taught
|
||||||
|
once, the places it appears listed as a *pattern*, samples for every field in
|
||||||
|
a [generated reference](../user/filters/fields.md) that cannot drift from the
|
||||||
|
UI.
|
||||||
|
|
||||||
|
## Why no screenshots
|
||||||
|
|
||||||
|
Screenshots fail in every way the rest of this repo's rot is already gated
|
||||||
|
against:
|
||||||
|
|
||||||
|
- **They rot silently.** No gate can diff pixels against the running UI. A
|
||||||
|
renamed button makes the screenshot wrong and nothing fails.
|
||||||
|
- **They cannot be verified.** Shown text can: this app's UI is
|
||||||
|
server-rendered from `templates/*.templ`, so every stable label and heading
|
||||||
|
is a literal in the source — checkable, like routes and API examples are.
|
||||||
|
- **They die with a redesign.** Text keyed to *function* survives a
|
||||||
|
re-layout. Text keyed to a layout dies with its redesign.
|
||||||
|
- **They exclude** (screen readers, text search) and bloat diffs.
|
||||||
|
|
||||||
|
The one thing screenshots do better — "which button is it?" — text does via
|
||||||
|
**exact quoted labels** plus a semantic path, and the label gate then holds
|
||||||
|
that text against the source.
|
||||||
|
|
||||||
|
## The layering — what is load-bearing
|
||||||
|
|
||||||
|
Write every UI page in this order. The top layers are why users don't get
|
||||||
|
lost when the UI changes:
|
||||||
|
|
||||||
|
| # | Layer | Content | Lifespan |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | **The pattern** | What the thing *is*, what is invariant, every place the same pattern appears | Redesign-proof |
|
||||||
|
| 2 | **Function inventory** | What the user *can do*, phrased without control names | Redesign-proof |
|
||||||
|
| 3 | **How-to with examples** | Numbered flows: current label → the outcome it produces | Survives most changes |
|
||||||
|
| 4 | **Current labels** | Exactly quoted, bold | Gate-checked today |
|
||||||
|
| 5 | **Generated references** | Enumerables regenerated from source with a currency test | Cannot drift |
|
||||||
|
|
||||||
|
Labels are **tips, not the map**. Layer 3 may quote a label; layer 1 must
|
||||||
|
make the page followable even if that label is renamed next week.
|
||||||
|
|
||||||
|
## The page template
|
||||||
|
|
||||||
|
1. **The pattern first.** The concept, its invariants, and everywhere it
|
||||||
|
appears — "one system, three places". Never page cartography.
|
||||||
|
2. **Function inventory.** Capabilities without control names: "you can pin
|
||||||
|
any narrowing to a named filter you can reopen later".
|
||||||
|
3. **How-to with examples.** Each step: the label of what you interact with,
|
||||||
|
then the outcome ("the count at the bottom shows how many books will
|
||||||
|
match"). An example beats an adjective.
|
||||||
|
4. **Findability note.** One line telling the reader how to re-locate the
|
||||||
|
feature *by function again* after any UI change — deliberately
|
||||||
|
label-independent ("it's the drawer opened from any books or podcasts
|
||||||
|
list — the only place whose header can read Match ALL").
|
||||||
|
5. **Current labels.** Quoted exactly as rendered, one canonical spelling
|
||||||
|
per control ([arbitrated by the glossary](ui-controls.md)).
|
||||||
|
|
||||||
|
## Writing rules
|
||||||
|
|
||||||
|
- **Semantic placement, never spatial.** No "top-right" or "at the bottom of
|
||||||
|
the screen" talk. "Devices page → the **Register Device** button" survives
|
||||||
|
any re-layout.
|
||||||
|
- **One canonical label per control.** Two spellings of the same control is
|
||||||
|
a defect, not a synonym.
|
||||||
|
- **Status updates get a what-it-means table**, not a color description.
|
||||||
|
- **Screenshots are never load-bearing.** Default is none.
|
||||||
|
|
||||||
|
## Tone by audience
|
||||||
|
|
||||||
|
- **User Guide (`docs/user/`) — plain language, deliberately.** Short
|
||||||
|
sentences. Everyday words. No developer jargon; any term the reader
|
||||||
|
genuinely needs (say, *rule*) is explained once, in place, then used
|
||||||
|
consistently. The reader is assumed to know books, while the app's own
|
||||||
|
words are the only technical vocabulary allowed.
|
||||||
|
- **Developer and Administration sections — tone is a separate discussion,
|
||||||
|
not yet decided.** Write them plainly and hand-off-rich as well; do not
|
||||||
|
import opinions about their voice until that pass happens.
|
||||||
|
|
||||||
|
## Generate what has a registry
|
||||||
|
|
||||||
|
Anything enumerable gets generated from source with a currency test, not
|
||||||
|
hand-typed. The precedent: every filter field is
|
||||||
|
[generated](../user/filters/fields.md) by `TestFieldReferenceDocIsCurrent`
|
||||||
|
from the same registry the menus render — the doc cannot disagree with the
|
||||||
|
UI. Hand-typed lists drift fastest; when no registry exists yet, say so in
|
||||||
|
the page and lean on the pattern layers.
|
||||||
|
|
||||||
|
## The gates
|
||||||
|
|
||||||
|
- `TestUIControlLabels` (`internal/docs/ui_labels_test.go`): every control in
|
||||||
|
the [glossary](ui-controls.md) must exist as a literal in
|
||||||
|
`templates/*.templ` (or `web/src` for TS-rendered surfaces, marked
|
||||||
|
`source: ts`). Renames fail `make test` in the same commit as the rename.
|
||||||
|
- `TestDocsRelativeLinks`: every relative link in the docs tree resolves to
|
||||||
|
a real file.
|
||||||
|
|
||||||
|
Naming a new control in prose? Add its glossary entry in the same commit.
|
||||||
|
Form labels inside one modal (Name, Description…) are cited inline and not
|
||||||
|
glossary-tracked; the glossary carries the controls people navigate by —
|
||||||
|
buttons, tabs, badges, page titles.
|
||||||
|
|
||||||
|
## The process rule
|
||||||
|
|
||||||
|
**A UI change is not finished until the docs audit runs.** Moved card,
|
||||||
|
renamed button, restructured page — all of it triggers: labels still
|
||||||
|
resolve, links still resolve, affected pages re-read against the new
|
||||||
|
surface. The pattern layers keep users oriented between audits; the audit
|
||||||
|
keeps layers 3–5 honest.
|
||||||
|
|
||||||
|
## What this does not replace
|
||||||
|
|
||||||
|
The filter documentation (and the older filtering overview) is hands-off
|
||||||
|
exemplar territory — read it as the model, never restyle it here. New UI
|
||||||
|
surfaces (scanning, devices, settings, podcasts, the reader, collections)
|
||||||
|
are written this way from the start.
|
||||||
@@ -623,6 +623,6 @@ This is a great example of how user feedback can improve implementation design!
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [User Guide: Calibre Integration](../../user/calibre-integration.md)
|
- [User Guide: Calibre Integration](../user/calibre-integration.md)
|
||||||
- [Implementation Plan](../../CALIBRE_OPF_IMPLEMENTATION.md)
|
- The original implementation plan file was removed from the repo when this page absorbed its content.
|
||||||
- [Scanner Source Code](../../internal/services/media_scanner.go)
|
- The scanner implementation lives in `internal/services/` (not hyperlinked — the docs site serves only docs).
|
||||||
+43
-80
@@ -13,57 +13,60 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
- **Device Setup**
|
- **Device Setup**
|
||||||
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
|
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
|
||||||
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
|
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
|
||||||
|
- [Android App](user/devices/android-app.md)
|
||||||
|
|
||||||
- **Sync Configuration**
|
- **Sync Configuration**
|
||||||
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
||||||
|
|
||||||
- **Frontend & Settings** (Coming Soon)
|
- **Filters**
|
||||||
- Frontend Guide
|
- [Filters — the complete guide](user/filters/index.md) - rules, groups (ALL/ANY), saved filters, smart collections
|
||||||
- User Areas Guide
|
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
|
||||||
- Settings Guide
|
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
|
||||||
- Admin Guide
|
|
||||||
|
- **Guides**
|
||||||
|
- [Reader Guide](user/reader-guide.md) - reading, highlights, notes
|
||||||
|
- [Library Browsing](user/library-browsing.md) - browsing and searching your library
|
||||||
|
- [Collections](user/collections.md) - manual and ⚡ smart shelves, books and episodes
|
||||||
|
- [Podcasts](user/podcasts.md) - feeds, downloads, listening
|
||||||
|
- [Dashboard](user/dashboard.md)
|
||||||
|
- [Themes](user/themes.md)
|
||||||
|
- [Calibre Integration](user/calibre-integration.md)
|
||||||
|
- [Profile Guide](user/profile-guide.md)
|
||||||
|
- [Admin Guide](user/admin-guide.md)
|
||||||
|
|
||||||
### 👨💻 For Developers
|
### 👨💻 For Developers
|
||||||
|
|
||||||
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
|
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
|
||||||
|
|
||||||
- **API Documentation**
|
- **API Documentation** — every endpoint is documented individually; the links below are entry points
|
||||||
- [Complete API Reference](developer/api-reference.md) - Monolithic REST API reference (1,300+ lines)
|
- [API Reference overview](developer/api/api-reference.md) - index of all endpoint docs
|
||||||
- [Split Endpoint Docs](developer/api/api-reference.md) - Individual endpoint docs
|
- [Collections API](developer/api/collections/list_collections.md) - collections management API
|
||||||
- [Collections API](developer/api/collections/list_collections.md) - Collections management API
|
- [Media Items API](developer/api/media-items/list_media_items.md) - media item operations including bulk operations and downloads
|
||||||
- [Admin API](developer/api/admin/) - User management (admin operations)
|
- [Devices API](developer/api/devices/list_devices.md) - Device sync and shelf management
|
||||||
- [Media Items API](developer/api/media-items/) - Media item operations including bulk operations and downloads
|
- [Conflicts API](developer/api/conflicts/list_conflicts.md) - Sync conflict resolution endpoints
|
||||||
- [Devices API](developer/api/devices/) - Device sync and shelf management
|
- [Queue API](developer/api/queue/list_all_queue_items.md) - Sync queue management endpoints
|
||||||
- [Conflicts API](developer/api/conflicts/) - Sync conflict resolution endpoints
|
- [Scanner API](developer/api/scanner/overview.md) - Library scanning and automated watch mode (admin)
|
||||||
- [Queue API](developer/api/queue/) - Sync queue management endpoints
|
- [KOReader API](developer/api/koreader/get_library.md) - KOReader sync protocol endpoints
|
||||||
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
- [Kobo API](developer/api/kobo/sync_from_server.md) - Kobo sync protocol endpoints
|
||||||
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
|
- [Admin API](developer/api/admin/list_users.md) - User management (admin operations); content-duplicate cleanup lives in [hash conflicts](developer/api/admin/hash_conflicts_api.md)
|
||||||
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
|
|
||||||
- [WebSocket API](developer/api/websocket/) - Real-time sync events
|
|
||||||
|
|
||||||
- **Protocol Specifications**
|
- **Protocol Specifications**
|
||||||
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
|
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
|
||||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon)
|
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
|
||||||
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
|
||||||
|
- **Writing UI docs?** Read [Writing UI documentation without screenshots](developer/writing-ui-docs.md) first.
|
||||||
|
|
||||||
### 🔧 For Operations
|
### 🔧 For Operations
|
||||||
|
|
||||||
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
|
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
|
||||||
|
|
||||||
- **Deployment**
|
|
||||||
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
|
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
|
||||||
|
|
||||||
- **Maintenance** (Coming Soon)
|
|
||||||
- Deployment Guide
|
|
||||||
- Maintenance Guide
|
|
||||||
- Monitoring Guide
|
|
||||||
|
|
||||||
### 🤝 For Contributors
|
### 🤝 For Contributors
|
||||||
|
|
||||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||||
|
|
||||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
- [Development Guide](contributing/development.md) - Architecture, setup, testing, and the project rules
|
||||||
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,11 +74,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
|
|
||||||
| Want to... | Go to |
|
| Want to... | Go to |
|
||||||
| -------------------- | ---------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------- |
|
||||||
| **Get started** | [README.md](../README.md) - Project overview and quick start |
|
| **Get started** | [Development Guide](contributing/development.md) - Architecture and quick start |
|
||||||
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
||||||
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
|
| **Use the API** | [API Reference overview](developer/api/api-reference.md) |
|
||||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
| **Deploy Bookhoard** | [Operations → Troubleshooting](operations/troubleshooting.md) |
|
||||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
| **Contribute code** | [Development Guide](contributing/development.md) |
|
||||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||||
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
||||||
|
|
||||||
@@ -87,65 +90,25 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
|
|
||||||
| Question | Answer |
|
| Question | Answer |
|
||||||
| --------------------------- | ------------------------------------------------------ |
|
| --------------------------- | ------------------------------------------------------ |
|
||||||
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
|
|
||||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
||||||
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
||||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||||
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
||||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
| ...use the API? | [API Reference overview](developer/api/api-reference.md) |
|
||||||
|
| ...write UI docs? | [Writing UI documentation](developer/writing-ui-docs.md) |
|
||||||
| ...contribute code? | [Development Guide](contributing/development.md) |
|
| ...contribute code? | [Development Guide](contributing/development.md) |
|
||||||
|
|
||||||
### "Where is..."
|
|
||||||
|
|
||||||
| Information | Location |
|
|
||||||
| ---------------------- | ----------------------------------------------------------- |
|
|
||||||
| ...features list? | [README.md](../README.md) |
|
|
||||||
| ...database schema? | [database/schema/schema.sql](../database/schema/schema.sql) |
|
|
||||||
| ...API endpoints? | [API Reference](developer/api-reference.md) |
|
|
||||||
| ...secrets config? | [.env.example](../.env.example) |
|
|
||||||
| ...operational config? | [docker-compose.yml](../docker-compose.yml) |
|
|
||||||
| ...deployment issues? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Common Workflows
|
|
||||||
|
|
||||||
### Set up a new device
|
|
||||||
|
|
||||||
1. Choose your device: [KOReader](user/devices/koreader-setup.md) (works on Kindle, Kobo, and PocketBook hardware)
|
|
||||||
2. Understand sync: [Sync Guide](user/sync-guide.md)
|
|
||||||
3. Troubleshoot: Device-specific guides
|
|
||||||
|
|
||||||
### Integrate with Bookhoard API
|
|
||||||
|
|
||||||
1. Start with [API Reference](developer/api-reference.md)
|
|
||||||
2. Explore [Collections API](developer/api/collections/list_collections.md)
|
|
||||||
3. Review [WebSocket API](developer/websocket-api.md)
|
|
||||||
4. Test with [bruno/](../bruno/) collections
|
|
||||||
|
|
||||||
### Deploy to production
|
|
||||||
|
|
||||||
1. Follow [README.md](../README.md) quick start
|
|
||||||
2. Configure environment: [.env.example](../.env.example)
|
|
||||||
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
|
|
||||||
4. Check [Development Guide](contributing/development.md) for performance tuning
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Contributing to Documentation
|
## 📝 Contributing to Documentation
|
||||||
|
|
||||||
When adding new features:
|
When adding new features:
|
||||||
|
|
||||||
1. **User-facing features** → Update relevant User docs
|
1. **User-facing features** → Update the relevant User docs
|
||||||
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
|
2. **API endpoints** → Update the endpoint docs under `developer/api/` (start from the [API Reference overview](developer/api/api-reference.md))
|
||||||
3. **Backend changes** → Update [Development Guide](contributing/development.md)
|
3. **Backend changes** → Update the [Development Guide](contributing/development.md)
|
||||||
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
4. **Deployment changes** → Update the [Operations Portal](operations/operations.md)
|
||||||
|
5. **New UI controls in docs** → Add them to the [UI controls glossary](developer/ui-controls.md)
|
||||||
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards.
|
6. **UI appearances in docs** → Follow [Writing UI documentation without screenshots](developer/writing-ui-docs.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: August 2026
|
|
||||||
**Bookhoard Version**: 1.0
|
|
||||||
@@ -15,19 +15,19 @@ Welcome to the Bookhoard operations documentation. This section contains guides
|
|||||||
|
|
||||||
### Additional Deployment Guides
|
### Additional Deployment Guides
|
||||||
|
|
||||||
**[Deployment Guide](deployment.md)** - Comprehensive deployment guide
|
**Deployment Guide** - Comprehensive deployment guide
|
||||||
|
|
||||||
- _Coming Soon_
|
- _Coming Soon_
|
||||||
|
|
||||||
## 🛠️ Maintenance
|
## 🛠️ Maintenance
|
||||||
|
|
||||||
**[Maintenance Guide](maintenance.md)** - Ongoing operations and maintenance
|
**Maintenance Guide** - Ongoing operations and maintenance
|
||||||
|
|
||||||
- _Coming Soon_
|
- _Coming Soon_
|
||||||
|
|
||||||
## 📊 Monitoring
|
## 📊 Monitoring
|
||||||
|
|
||||||
**[Monitoring Guide](monitoring.md)** - Monitoring and alerting
|
**Monitoring Guide** - Monitoring and alerting
|
||||||
|
|
||||||
- _Coming Soon_
|
- _Coming Soon_
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ This guide will cover:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_In the meantime, check out our [Operations Portal](../operations/)_
|
_In the meantime, check out our [Operations Portal](../operations/operations.md)_
|
||||||
@@ -139,7 +139,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
|
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
|
||||||
2. Trigger a scan via the **Scanner API**, or let watch mode pick up the changed files (the File Watcher status is shown on the admin dashboard)
|
2. Trigger a scan from the admin interface's scan controls, or let watch mode pick up the changed files automatically
|
||||||
3. Bookhoard imports all 500 books with:
|
3. Bookhoard imports all 500 books with:
|
||||||
- Correct titles and authors
|
- Correct titles and authors
|
||||||
- Series information (e.g., "Harry Potter #2")
|
- Series information (e.g., "Harry Potter #2")
|
||||||
@@ -168,7 +168,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
**Steps**:
|
**Steps**:
|
||||||
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
||||||
2. In Bookhoard, trigger a rescan:
|
2. In Bookhoard, trigger a rescan:
|
||||||
- Via the **Scanner API** (`POST /api/scanner/scan`), or
|
- Via the admin interface's scan controls, or
|
||||||
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
||||||
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
|
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
|
||||||
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
|
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
|
||||||
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically)
|
3. **Force rescan**: Trigger a rescan from the admin interface to re-import all metadata (watch mode also picks up changed files automatically)
|
||||||
4. **Check logs**: Review Bookhoard logs for parsing errors
|
4. **Check logs**: Review Bookhoard logs for parsing errors
|
||||||
|
|
||||||
### Incorrect Metadata
|
### Incorrect Metadata
|
||||||
@@ -332,11 +332,10 @@ file is never touched if anything fails (temp file + atomic rename).
|
|||||||
- [Calibre Website](https://calibre-ebook.com/)
|
- [Calibre Website](https://calibre-ebook.com/)
|
||||||
- [Calibre User Manual](https://manual.calibre-ebook.com/)
|
- [Calibre User Manual](https://manual.calibre-ebook.com/)
|
||||||
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
|
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
|
||||||
- [Bookhoard GitHub](https://github.com/yourusername/bookhoard)
|
|
||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
|
|
||||||
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
|
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
|
||||||
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
|
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
|
||||||
- **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details
|
- **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details
|
||||||
- **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/yourusername/bookhoard/issues)
|
- **Issues**: Report bugs or request features on your repository's issue tracker
|
||||||
+101
-25
@@ -1,42 +1,118 @@
|
|||||||
# Collections
|
# Collections
|
||||||
|
|
||||||
Collections allow you to organize books across multiple libraries.
|
A collection is a personal shelf you name yourself. You put your books and
|
||||||
|
podcast episodes on it, and it appears as a section on your dashboard.
|
||||||
|
|
||||||
## Viewing Collections
|
That is the whole idea. Everything below is variations on it.
|
||||||
|
|
||||||
### From Collections Page
|
## The one pattern, four flavors
|
||||||
|
|
||||||
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
|
Every collection behaves the same way on the outside: it has a name, an icon,
|
||||||
|
a color, and a list of items. The flavors differ in how items *join*:
|
||||||
|
|
||||||
### From Dashboard
|
| Flavor | How items join | Right for |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Static** | You pick each item by hand | Curated lists — "books for the trip", "new house ideas" |
|
||||||
|
| **⚡ Smart** (books) | Rules match books live, forever | "everything by Asimov", "still not started", "over 400 pages" |
|
||||||
|
| **⚡ Smart** (podcast episodes) | Rules match episodes live — and they can play back-to-back | "catch up on my feeds", "episodes to finish" |
|
||||||
|
| **System** (built-in) | Bookhoard maintains them | Continue Reading, Recently Added, Recently Read, Not Started, Continue Series |
|
||||||
|
|
||||||
When viewing a specific library's dashboard, collections only show books from that library. Clicking "View All" preserves this context and shows only books from that library in the collection.
|
A collection holds **books and podcast episodes** — mix them if you like.
|
||||||
|
|
||||||
### Library Filtering
|
The **⚡ Smart** badge means the same thing everywhere it appears: *membership
|
||||||
|
is matched by rules right now — always current, nothing to refresh*.
|
||||||
|
|
||||||
- **No filter** (`/collections/favorites`): Shows all books across all libraries
|
## Creating a collection
|
||||||
- **With filter** (`/collections/favorites?library_id=xxx`): Shows only books from the specified library
|
|
||||||
|
|
||||||
## Creating Collections
|
1. Open the **Collections** page (sidebar navigation)
|
||||||
|
2. Click **New Collection**
|
||||||
|
3. Choose the **Type**:
|
||||||
|
- **Static** — pick items by hand
|
||||||
|
- **⚡ Smart** — match by rules, always live
|
||||||
|
4. If you picked **⚡ Smart**, choose the **Rules scope**: **📚 Books** or
|
||||||
|
**🎧 Podcast episodes**
|
||||||
|
5. Fill in the rest: **Name**, **Description**, an **Icon** you can search or
|
||||||
|
pick, and a **Color**
|
||||||
|
6. Click **Create Collection**
|
||||||
|
|
||||||
1. Go to **Collections** (sidebar navigation)
|
Starting from a completely empty account? The button **Create Your First
|
||||||
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
|
Collection** does the same thing.
|
||||||
3. Fill in the details:
|
|
||||||
- **Name** - Collection name
|
|
||||||
- **Description** - Optional description
|
|
||||||
- **Icon** - Pick from the icon grid
|
|
||||||
- **Color** - Pick a color swatch
|
|
||||||
4. Click **Create Collection**
|
|
||||||
|
|
||||||
## Managing Collections
|
## Adding items by hand (Static collections)
|
||||||
|
|
||||||
Each collection in the list has icon buttons on its card:
|
1. Open the collection from the **Collections** page
|
||||||
|
2. Click **Add Items**
|
||||||
|
3. Pick the tab for what you want:
|
||||||
|
- **📚 Books** — searchable by the **Search books...** box, and the
|
||||||
|
**Author** and **Genre** boxes
|
||||||
|
- **🎧 Episodes** — the episodes of the feeds you subscribe to
|
||||||
|
4. Click one or more items — a checkmark marks what you picked; the bottom
|
||||||
|
of the window counts them for you
|
||||||
|
5. Click **Add Selected**
|
||||||
|
|
||||||
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save.
|
To take items out, check them on the collection page and click
|
||||||
- **Delete** - Removes the collection after a confirmation prompt.
|
**Remove Selected** (it asks first).
|
||||||
|
|
||||||
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections.
|
While checking what is in the collection, the **Search within collection...**
|
||||||
|
box narrows the view — it never removes anything.
|
||||||
|
|
||||||
### Dashboard Sections
|
## Rules (how ⚡ Smart collections work)
|
||||||
|
|
||||||
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)).
|
A smart collection keeps its own list of rules, the same rules that power the
|
||||||
|
filter drawer. On the collection's page, click **Edit Rules** to open the
|
||||||
|
rules editor: build your groups and rules in the **Filter tree**, then click
|
||||||
|
**Apply to Collection**. From that point the collection matches those rules
|
||||||
|
by itself — add a book that matches and it appears without you doing
|
||||||
|
anything.
|
||||||
|
|
||||||
|
Rules live in one guide, so this page does not repeat them. The full how-to
|
||||||
|
walks through groups, ALL/ANY, and value picking in
|
||||||
|
[Filters — the complete guide](filters/index.md), ready-made examples are in
|
||||||
|
the [cookbook](filters/cookbook.md), and
|
||||||
|
[every filter field](filters/fields.md) lists what you can ask about.
|
||||||
|
|
||||||
|
*Finding it again later:* open any collection's page and look for the
|
||||||
|
**Edit Rules** button; the **⚡ Smart** badge in the page title marks a
|
||||||
|
collection that is already matching.
|
||||||
|
|
||||||
|
## Playing an episode collection
|
||||||
|
|
||||||
|
When a collection holds a downloaded episode, it grows a **Play as Sequence**
|
||||||
|
button: the episodes play one after another. Next to it, the order picker
|
||||||
|
decides the sequence — **Feed order**, **Feed order (reversed)**,
|
||||||
|
**Oldest first (pub date)**, or **Newest first (pub date)**. Your choice is
|
||||||
|
remembered on the collection.
|
||||||
|
|
||||||
|
## Changing a collection
|
||||||
|
|
||||||
|
The pencil button on a collection card, or the edit control on the
|
||||||
|
collection page, opens the edit form:
|
||||||
|
|
||||||
|
- Change the **Name**, **Description**, **Icon**, **Color**
|
||||||
|
- **Hide finished books (reading-queue mode)** — finished books drop off
|
||||||
|
this shelf, which keeps a to-read list short
|
||||||
|
- **Show Up Next strip** — show the collection's Up Next strip where it is
|
||||||
|
used
|
||||||
|
|
||||||
|
Click **Update Collection** to save. Deleting a collection asks first, and
|
||||||
|
deleting only removes the shelf — never the books or episodes inside it.
|
||||||
|
|
||||||
|
## System collections
|
||||||
|
|
||||||
|
The five built-in shelves — **Continue Reading**, **Recently Added**,
|
||||||
|
**Recently Read**, **Not Started**, **Continue Series** — work like any
|
||||||
|
collection on the outside, but Bookhoard decides their membership by what
|
||||||
|
happens while you read. They appear on your dashboard automatically.
|
||||||
|
|
||||||
|
Deleted one and want it back? Click **Restore System** on the **Collections**
|
||||||
|
page, pick the shelf from the list, and click **Restore**.
|
||||||
|
|
||||||
|
## Your dashboard shelves
|
||||||
|
|
||||||
|
Collections appear as sections on your dashboard, and you choose which ones
|
||||||
|
show and in what order from the dashboard's own settings. That page's
|
||||||
|
details live in [Dashboard](dashboard.md).
|
||||||
|
|
||||||
|
*Finding your way back:* **Collections** is the sidebar page whose top has
|
||||||
|
the only **New Collection** button — from a dashboard shelf's **View All**,
|
||||||
|
the collection detail page is the place with **Add Items**.
|
||||||
+45
-35
@@ -1,52 +1,62 @@
|
|||||||
# Dashboard
|
# Dashboard
|
||||||
|
|
||||||
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
|
The dashboard is your home screen: horizontal shelves ("sections") of your
|
||||||
|
books, one row after another. What each shelf contains depends on the library
|
||||||
|
you are looking at — including the **All Libraries** view, which mixes
|
||||||
|
everything you can see.
|
||||||
|
|
||||||
## Sections
|
## The three kinds of section
|
||||||
|
|
||||||
### Smart Sections
|
| Kind | What fills it |
|
||||||
|
| --- | --- |
|
||||||
|
| **System shelves** | Bookhoard maintains them from your reading: **Continue Reading**, **Recently Added**, **Recently Read**, **Not Started**, **Continue Series** |
|
||||||
|
| **Your collections** | Every collection you create appears as its own shelf |
|
||||||
|
| **Custom sections** | Shelves you define yourself — by filter rules or by hand-picking books |
|
||||||
|
|
||||||
Smart sections are automatically generated based on your reading activity:
|
System shelves and collections are covered by their own guides (see
|
||||||
|
[Sync Guide](sync-guide.md) for what "continue reading" means across devices,
|
||||||
|
and [Collections](collections.md) for the shelves you build). This page is
|
||||||
|
about arranging the rows.
|
||||||
|
|
||||||
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
|
### This Week
|
||||||
- **In Progress**: Books you've started (progress > 0%)
|
|
||||||
- **Recently Added**: Newest items added to this library
|
|
||||||
- **Recently Read**: Books you've completed (100% progress)
|
|
||||||
- **Not Started**: Books you haven't read yet
|
|
||||||
|
|
||||||
### User Collections
|
The dashboard also shows a small **This Week** summary — your reading time
|
||||||
|
and activity for the week so far. Click it to expand the detail.
|
||||||
|
|
||||||
Your collections appear as sections on the dashboard. To show or hide a collection's section:
|
## Customizing your shelves
|
||||||
|
|
||||||
1. Select the library in the **Library** bar
|
1. Pick the library in the **Library** bar (a specific library, or
|
||||||
2. Click the **Customize Dashboard** icon button
|
**All Libraries**) — settings are saved per library
|
||||||
3. Toggle the collection on or off
|
2. Click the **Customize Dashboard** button in the Library bar
|
||||||
4. Click "Save Changes"
|
3. In the **Customize Dashboard** panel: drag sections into the order you
|
||||||
|
want, switch sections on or off, and set how many items each section
|
||||||
|
shows (10–50)
|
||||||
|
4. Click **Save Changes**
|
||||||
|
|
||||||
### Customizing Your Dashboard
|
The **Refresh** button next to it re-reads your libraries without a full
|
||||||
|
page reload.
|
||||||
|
|
||||||
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
|
## Custom sections
|
||||||
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
|
|
||||||
3. **Drag sections** to reorder them
|
|
||||||
4. **Toggle visibility** with the switches
|
|
||||||
5. **Adjust items per section** (10-50 items)
|
|
||||||
6. Click "Save Changes"
|
|
||||||
|
|
||||||
Settings are saved per library.
|
A custom section is a shelf whose contents you define — either with the same
|
||||||
|
filter rules the **Filters** drawer uses, or by picking the books yourself.
|
||||||
|
You build one on the **Create Custom Section** page (at `/custom-section` on
|
||||||
|
your server); once saved it behaves like any other shelf and can be ordered
|
||||||
|
and hidden with everything else.
|
||||||
|
|
||||||
### Library Switching
|
## Reading and listening from the dashboard
|
||||||
|
|
||||||
Use the **Library** dropdown in the bar below the top bar to switch between libraries (including "All Libraries"). Each library has its own dashboard settings.
|
Point at a shelf and chevron buttons appear to scroll it. Every item carries
|
||||||
|
a direct action: **Read** opens the web reader, **Listen** opens the audio
|
||||||
|
player. If the same book has conflicting progress from two devices, the item
|
||||||
|
grows a resolve action that takes you to conflict resolution — see the
|
||||||
|
[Sync Guide](sync-guide.md).
|
||||||
|
|
||||||
### Keyboard Navigation
|
## Keyboard and touch
|
||||||
|
|
||||||
- **Tab**: Navigate between sections and books
|
- **Tab** moves between shelves and books; **Enter** opens the focused book
|
||||||
- **Arrow Keys**: Scroll carousels horizontally
|
- **Arrow keys** scroll the focused shelf sideways
|
||||||
- **Enter**: Open selected book
|
- On touch: swipe a shelf to scroll it, tap a cover to open it
|
||||||
|
|
||||||
Hovering a carousel shows chevron buttons on either side for scrolling.
|
*Finding your way back:* the dashboard is the page with the **Library** bar —
|
||||||
|
the only place with the **Customize Dashboard** button.
|
||||||
### Touch Gestures (Mobile)
|
|
||||||
|
|
||||||
- **Swipe**: Drag carousel left/right to scroll
|
|
||||||
- **Tap**: Open book details
|
|
||||||
@@ -1,40 +1,47 @@
|
|||||||
# Kobo Device Setup Guide
|
# Kobo Device Setup Guide
|
||||||
|
|
||||||
> ## 🚧 Coming Soon
|
There are two ways to use a Kobo with Bookhoard, and they can be chosen
|
||||||
>
|
independently per device:
|
||||||
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
|
|
||||||
|
|
||||||
## Using a Kobo With Bookhoard Today
|
| Way | Status | What you get |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **KOReader on the Kobo** | Works today, fully | Reading position, bookmarks, highlights, notes, OPDS wireless book delivery |
|
||||||
|
| **Stock Kobo firmware (native sync)** | Server support is complete; device-side enablement is the remaining piece | Reading position and stats, bookmarks/highlights/notes, shelf mappings (your collections as Kobo shelves), automatic EPUB → KEPUB conversion, and the Kobo's own sync experience |
|
||||||
|
|
||||||
You don't have to wait: **KOReader runs on Kobo hardware** and syncs fully with Bookhoard today — reading position, bookmarks, highlights, and notes, plus OPDS wireless book delivery.
|
## Using a Kobo today
|
||||||
|
|
||||||
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
|
**KOReader runs on Kobo hardware** and syncs fully with Bookhoard right
|
||||||
|
now — the [KOReader Setup Guide](koreader-setup.md) walks the whole path
|
||||||
|
(plugin install, server approval, OPDS delivery, troubleshooting).
|
||||||
|
|
||||||
## What's Planned for Native Kobo Sync
|
## About native Kobo sync
|
||||||
|
|
||||||
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard:
|
The server side of the native protocol is implemented and documented: the
|
||||||
|
Kobo sync and initialization endpoints, bookmark and markup (highlight and
|
||||||
|
note) sync, shelf mappings that expose your collections as Kobo shelves,
|
||||||
|
and the KEPUB sidecar configuration used for conversion. What remains is
|
||||||
|
the device-side rollout, so stock-firmware sync is not yet something to
|
||||||
|
rely on for daily use — this guide will gain a full setup walkthrough when
|
||||||
|
that lands.
|
||||||
|
|
||||||
- **Reading position sync** — percentages, pages, and reading statistics
|
Nothing about native sync takes anything away from KOReader: the two can
|
||||||
- **Bookmarks, highlights, and notes** — synced with the web and other devices
|
coexist, and a KOReader setup keeps working either way.
|
||||||
- **OPDS wireless delivery** — browse and download books directly on the Kobo
|
|
||||||
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
|
|
||||||
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
|
|
||||||
|
|
||||||
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Should I buy a Kobo to use with Bookhoard today?**
|
**Q: Should I buy a Kobo to use with Bookhoard today?**
|
||||||
A: Kobo devices work great with Bookhoard via KOReader. Native (stock firmware) sync is coming soon.
|
A: Yes, via KOReader — it is the supported path today and covers sync plus
|
||||||
|
wireless book delivery.
|
||||||
|
|
||||||
**Q: What happens to my KOReader setup when native sync arrives?**
|
**Q: Will I have to redo anything when native sync lands?**
|
||||||
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
|
A: No — KOReader keeps working; native sync just adds the stock-firmware
|
||||||
|
option.
|
||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
||||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||||
- [Bookhoard API Reference](../../developer/api-reference.md)
|
- Kobo sync protocol (for developers): [Kobo API docs](../../developer/api/kobo/sync_from_server.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
|
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, approval
|
||||||
|
flow) + the server-side koreader endpoints. Re-verify against the plugin
|
||||||
|
source when the plugin changes. -->
|
||||||
|
|
||||||
# KOReader Device Setup Guide
|
# KOReader Device Setup Guide
|
||||||
|
|
||||||
This guide will help you set up KOReader on your e-reader device to sync with Bookhoard.
|
This guide will help you set up KOReader on your e-reader device to sync with
|
||||||
|
Bookhoard — reading position, bookmarks, highlights, notes, and even your
|
||||||
|
ratings, reviews, and reading time.
|
||||||
|
|
||||||
## What is KOReader?
|
## What is KOReader?
|
||||||
|
|
||||||
KOReader is an open-source e-reader application that supports a wide range of e-reader devices including:
|
KOReader is an open-source e-reader application that supports a wide range of
|
||||||
|
e-reader devices including:
|
||||||
|
|
||||||
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
||||||
- Kobo devices (Clara, Aura, Nia, etc.)
|
- Kobo devices (Clara, Aura, Nia, etc.)
|
||||||
- PocketBook devices
|
- PocketBook devices
|
||||||
|
|
||||||
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there.
|
It also runs on Android tablets and phones — though on Android the dedicated
|
||||||
|
[Bookhoard app](android-app.md) is the better option.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -67,17 +75,27 @@ Before you begin, make sure you have:
|
|||||||
|
|
||||||
## Connecting KOReader to Bookhoard
|
## Connecting KOReader to Bookhoard
|
||||||
|
|
||||||
Setup is done **on the server**: you approve the device from the Bookhoard web interface — no usernames, passwords, or tokens to type on the device.
|
Setup is done **on the server**: you approve the device from the Bookhoard
|
||||||
|
web interface — no usernames, passwords, or tokens to type on the device.
|
||||||
|
|
||||||
### Step 1: Install the Bookhoard Plugin
|
### Step 1: Install the Bookhoard Plugin
|
||||||
|
|
||||||
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
Installation is a git clone straight into your KOReader `plugins/` folder —
|
||||||
2. Copy it to your KOReader `plugins/` directory
|
no download-and-copy step:
|
||||||
3. Restart KOReader
|
|
||||||
|
```bash
|
||||||
|
cd koreader/plugins # .adds/koreader/plugins on Kobo
|
||||||
|
git clone https://git.linuxhg.com/Bookhoard/bookhoard.koplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart KOReader.
|
||||||
|
|
||||||
|
**Updating later** is the same, smaller: in the `bookhoard.koplugin` folder,
|
||||||
|
`git pull`, then restart KOReader.
|
||||||
|
|
||||||
### Step 2: Point the Plugin at Your Server
|
### Step 2: Point the Plugin at Your Server
|
||||||
|
|
||||||
1. Open KOReader, tap the **wrench icon** at the top
|
1. Open KOReader, tap the **wrench icon** at the top (the tools menu)
|
||||||
2. Find and tap **Bookhoard sync**
|
2. Find and tap **Bookhoard sync**
|
||||||
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
||||||
|
|
||||||
@@ -87,34 +105,116 @@ Setup is done **on the server**: you approve the device from the Bookhoard web i
|
|||||||
|
|
||||||
Use your server's LAN IP (or domain if you have one configured).
|
Use your server's LAN IP (or domain if you have one configured).
|
||||||
|
|
||||||
### Step 3: Approve the Device in Bookhoard
|
### Step 3: Register the Device
|
||||||
|
|
||||||
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation)
|
1. In the same **Bookhoard sync** menu, tap **Register device**
|
||||||
2. Refresh the page — your device appears under **Pending Device Registrations**
|
2. On your computer or phone, open Bookhoard and go to the **Devices** page
|
||||||
3. Click **Approve** to connect the device
|
(sidebar navigation)
|
||||||
|
3. Your device appears under **Pending Device Registrations** — click
|
||||||
|
**Approve** to connect it
|
||||||
|
|
||||||
Once approved, the plugin picks up its credentials automatically — reading progress sync and OPDS catalog access are set up automatically. No further configuration is needed.
|
The plugin waits on the device and finishes by itself the moment you
|
||||||
|
approve. Once approved, its credentials are stored automatically, sync
|
||||||
|
events are armed, and Bookhoard's OPDS catalog is added to your KOReader —
|
||||||
|
no further configuration is needed.
|
||||||
|
|
||||||
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again.
|
> **Note:** Pending registrations expire after 5 minutes. If yours expires
|
||||||
|
> ("Registration expired"), just tap **Register device** again and approve.
|
||||||
|
|
||||||
### Auth Token (Advanced)
|
### Auth Token (Advanced)
|
||||||
|
|
||||||
The Devices page shows each KOReader device's **Auth Token**. You normally never need it (the plugin receives it automatically during approval), but it can be re-entered manually in the plugin settings if you're moving a setup between devices or debugging.
|
The Devices page shows each KOReader device's **Auth Token**. You normally
|
||||||
|
never need it (the plugin receives it automatically during approval), but it
|
||||||
|
can be re-entered manually in the plugin settings if you're moving a setup
|
||||||
|
between devices or debugging.
|
||||||
|
|
||||||
|
## The Bookhoard sync menu
|
||||||
|
|
||||||
|
Everything the plugin can do lives in one place: **wrench icon → Bookhoard
|
||||||
|
sync**. The menu changes shape depending on whether you are connected.
|
||||||
|
|
||||||
|
### Before you're connected
|
||||||
|
|
||||||
|
- **Server URL** — your server's address
|
||||||
|
- **Register device** — starts the approval flow above
|
||||||
|
|
||||||
|
### Once connected — one-tap actions
|
||||||
|
|
||||||
|
| Menu item | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| **Push progress from this device** | Force-push this device's position to the server |
|
||||||
|
| **Pull progress from server** | Force-pull the server's position onto this device |
|
||||||
|
| **Sync rating, review & time now** | Push your rating, review, and accumulated reading time for the current book immediately |
|
||||||
|
| **Setup OPDS catalog** | (Re-)adds Bookhoard's catalog to KOReader's OPDS list — normally done for you at approval |
|
||||||
|
| **Device info** | Shows this device's ID and the server it talks to |
|
||||||
|
| **Disconnect** | Forgets the device's credentials (after a confirmation) |
|
||||||
|
|
||||||
|
### Automatically push progress
|
||||||
|
|
||||||
|
This toggle is the on/off switch for background sync — **it starts off**, so
|
||||||
|
after approval, turn it on to get automatic syncing. With it on, progress
|
||||||
|
pushes as you read and on close/suspend; the plugin may prompt about turning
|
||||||
|
Wi-Fi on at those moments depending on your device.
|
||||||
|
|
||||||
|
Two related settings sit under it:
|
||||||
|
|
||||||
|
- **Periodically sync every # pages** — how many page turns between updates
|
||||||
|
(default 50; set 0 to disable periodic updates)
|
||||||
|
- **Sync mode** — **Immediate** pushes right away, **Checkpoint** batches
|
||||||
|
changes for slower connections or battery saving
|
||||||
|
|
||||||
|
### Sync behavior — who wins when states differ
|
||||||
|
|
||||||
|
These two settings decide what the device does when its progress and the
|
||||||
|
server's disagree, without you opening the Conflicts page:
|
||||||
|
|
||||||
|
- **Sync to a newer state** — the server has *newer* progress than this
|
||||||
|
device (default **Prompt**: the device asks before adopting it; alternatives
|
||||||
|
are **Silently** or **Never**)
|
||||||
|
- **Sync to an older state** — this device has *newer* progress than the
|
||||||
|
server (default **Never**: the device never rolls itself back)
|
||||||
|
|
||||||
|
Set both to **Silently** for hands-off syncing, or leave the defaults if you
|
||||||
|
want a say whenever progress would move.
|
||||||
|
|
||||||
|
### What to sync
|
||||||
|
|
||||||
|
Seven toggles, all on by default, each controlling one kind of data:
|
||||||
|
|
||||||
|
- **Reading progress**
|
||||||
|
- **Bookmarks**
|
||||||
|
- **Highlights**
|
||||||
|
- **Notes**
|
||||||
|
- **Ratings** — your book ratings made in KOReader
|
||||||
|
- **Book review** — your review text
|
||||||
|
- **Reading time** — time-spent statistics
|
||||||
|
|
||||||
|
Turn off anything you'd rather keep local; the rest keeps flowing both ways.
|
||||||
|
|
||||||
## What Syncs
|
## What Syncs
|
||||||
|
|
||||||
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
|
Once connected and enabled, the following sync in both directions between
|
||||||
|
KOReader and Bookhoard (web, Android, and other devices), each governed by
|
||||||
|
its toggle above:
|
||||||
|
|
||||||
- **Reading position** — percentage, chapter, and EPUB CFI where available
|
- **Reading position** — percentage, chapter, and EPUB CFI where available
|
||||||
- **Bookmarks**
|
- **Bookmarks**
|
||||||
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes
|
- **Highlights** — including highlight colors, mapped between the web and
|
||||||
|
KOReader palettes
|
||||||
- **Notes** — standalone and attached to highlights
|
- **Notes** — standalone and attached to highlights
|
||||||
|
- **Ratings, review, and reading time** — pushed to the server (push any
|
||||||
|
time from the menu with **Sync rating, review & time now**)
|
||||||
|
|
||||||
Books are matched automatically using UUIDs, file hashes (SHA-256, format-aware so converted files still match), file aliases, and title/author fallback. If a book can't be matched, it shows up under the device's **Unlinked Books** in Bookhoard, where you can link it manually.
|
Books are matched automatically using UUIDs, file hashes (SHA-256,
|
||||||
|
format-aware so converted files still match), file aliases, and
|
||||||
|
title/author fallback. If a book can't be matched, it shows up under the
|
||||||
|
device's **Unlinked Books** in Bookhoard, where you can link it manually.
|
||||||
|
|
||||||
## OPDS Wireless Book Delivery
|
## OPDS Wireless Book Delivery
|
||||||
|
|
||||||
Once your device is approved, the plugin also registers Bookhoard's OPDS catalog, so you can browse and download books wirelessly — no USB cable needed.
|
At approval the plugin adds Bookhoard's OPDS catalog to KOReader (and
|
||||||
|
**Setup OPDS catalog** re-adds it any time), so you can browse and download
|
||||||
|
books wirelessly — no USB cable needed.
|
||||||
|
|
||||||
### Browse and Download Books
|
### Browse and Download Books
|
||||||
|
|
||||||
@@ -129,7 +229,8 @@ Once your device is approved, the plugin also registers Bookhoard's OPDS catalog
|
|||||||
- **PDF**: Fixed-layout documents
|
- **PDF**: Fixed-layout documents
|
||||||
- **CBZ**: Comic book archives
|
- **CBZ**: Comic book archives
|
||||||
|
|
||||||
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
|
Books downloaded via OPDS are automatically matched to your library, so
|
||||||
|
their progress syncs from the first page.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -142,7 +243,7 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
|||||||
1. Verify the Server URL is correct (no trailing path — just the base address)
|
1. Verify the Server URL is correct (no trailing path — just the base address)
|
||||||
2. Make sure KOReader is connected to Wi-Fi
|
2. Make sure KOReader is connected to Wi-Fi
|
||||||
3. Check the Bookhoard server is reachable from the device's network
|
3. Check the Bookhoard server is reachable from the device's network
|
||||||
4. Registrations expire after 5 minutes — re-run the sync and approve quickly
|
4. Registrations expire after 5 minutes — tap **Register device** again and approve quickly
|
||||||
|
|
||||||
### Connection Refused
|
### Connection Refused
|
||||||
|
|
||||||
@@ -161,11 +262,18 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
|||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
- Trigger a manual sync from the plugin menu
|
- **Automatically push progress** defaults to off — turn it on in the menu, or push once with **Push progress from this device**
|
||||||
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
|
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
|
||||||
- Verify the book appears as an unlinked book for the device and link it if needed
|
- Verify the book appears as an unlinked book for the device and link it if needed
|
||||||
- Check Bookhoard server logs for errors
|
- Check Bookhoard server logs for errors
|
||||||
|
|
||||||
|
### Turning on auto-sync asks about Wi-Fi
|
||||||
|
|
||||||
|
If enabling **Automatically push progress** shows a message about
|
||||||
|
**"Action when Wi-Fi is off"**, open KOReader's Network settings and set
|
||||||
|
that action to **turn on** — auto-sync needs permission to bring Wi-Fi up by
|
||||||
|
itself.
|
||||||
|
|
||||||
### Conflicts Detected
|
### Conflicts Detected
|
||||||
|
|
||||||
**Problem**: Sync conflicts when reading the same book on multiple devices
|
**Problem**: Sync conflicts when reading the same book on multiple devices
|
||||||
@@ -174,7 +282,10 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
|||||||
|
|
||||||
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
|
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
|
||||||
2. Review the progress reported by each device
|
2. Review the progress reported by each device
|
||||||
3. Choose which device's progress to keep
|
3. Choose a resolution: keep one side's progress, keep the server's, or let the furthest progress win
|
||||||
|
|
||||||
|
For day-to-day control of the same question on the device itself, use the
|
||||||
|
**Sync behavior** settings (newer state / older state).
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|
||||||
@@ -189,8 +300,3 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
|||||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
||||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||||
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: August 2026
|
|
||||||
**Bookhoard Version**: 1.0
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Filter cookbook
|
||||||
|
|
||||||
|
Real questions, exact clicks. Every recipe uses the same builder described in
|
||||||
|
[the guide](index.md) — same fields, same rules, wherever filters live.
|
||||||
|
|
||||||
|
Recipes marked **collection** become smart collections that stay current forever; the rest are
|
||||||
|
drawer filters you can save with a name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "New chapters of 7th Son, automatically" — collection
|
||||||
|
|
||||||
|
1. **Collections** → open (or create) a collection → **Rules**.
|
||||||
|
2. The scope is already *Episodes*. Add a rule: **Feed** → **in** → pick
|
||||||
|
*7th Son: Book One* (it becomes a chip — hit the × if you pick wrong).
|
||||||
|
3. Add a second rule: **Title** → **contains** → `Chapter`.
|
||||||
|
4. The header should read **MATCH ALL** (it does by default — every rule must match).
|
||||||
|
5. **Apply to Collection**. Done: new chapters appear on their own, and the listening order
|
||||||
|
starts at **Feed order (reversed)** — chapter 1 first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Lewis Carroll or George Orwell" — drawer
|
||||||
|
|
||||||
|
1. **Filters** on the All Books page. Add: **Author** → **contains** → `Carroll`.
|
||||||
|
2. Add a second rule: **Author** → **contains** → `Orwell`.
|
||||||
|
3. Click the group header **MATCH ALL** once — it flips to **MATCH ANY** (or click the `AND`
|
||||||
|
chip between the two rules, same effect).
|
||||||
|
4. **Apply Filters**. Either author is enough.
|
||||||
|
|
||||||
|
*Flip it back to ALL and you'd get only books they both wrote — which is none, and the counter
|
||||||
|
will tell you so before you apply.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Long fantasy books I haven't started" — drawer
|
||||||
|
|
||||||
|
1. **Genre** → **is** → `fantasy`.
|
||||||
|
2. **Page count** → **at least** → `400`.
|
||||||
|
3. **My reading** → **Reading status** → **is** → *Unfinished* (or your app's wording — the
|
||||||
|
status list is the fixed dropdown on that field).
|
||||||
|
4. **MATCH ALL** stays on: all three must hold.
|
||||||
|
5. Apply — then **Save** it as *"Big unread fantasy"* and it's in the Load menu forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Downloaded podcast episodes I haven't played" — collection
|
||||||
|
|
||||||
|
1. A collection with an Episodes scope (see the first recipe — drop the Title rule if you want
|
||||||
|
whole feeds).
|
||||||
|
2. **Downloaded** → **is true**.
|
||||||
|
3. **Played** → **is false**.
|
||||||
|
4. **MATCH ALL** on. **Apply to Collection**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Added to my library this month" — drawer
|
||||||
|
|
||||||
|
1. **Date added** → **within the last** → `30` (the days box — the field's page in
|
||||||
|
[Every filter field](fields.md) has its exact questions).
|
||||||
|
2. Optionally **MATCH ALL** with **Library** → **in** → your audiobooks library.
|
||||||
|
3. Save as *"New arrivals"*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "A dashboard shelf that builds itself" — dashboard
|
||||||
|
|
||||||
|
1. Create a collection with the rules above (or open one).
|
||||||
|
2. On the dashboard, add a section pointed at that collection.
|
||||||
|
3. The shelf re-fills itself as your library changes — no maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing a filtered view
|
||||||
|
|
||||||
|
Apply any filter, then copy the address bar. The filter rides in the URL — send it to another
|
||||||
|
user of your server and they open the same narrowed view (they see it through their own
|
||||||
|
libraries, not yours).
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)
|
||||||
|
from the filter registry — the same source the real menus render. DO NOT EDIT by hand:
|
||||||
|
regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->
|
||||||
|
|
||||||
|
# Every filter field
|
||||||
|
|
||||||
|
This is the complete list of everything the filters can ask about, generated straight from the
|
||||||
|
same source the app's menus render — it cannot drift from what you see in the UI.
|
||||||
|
|
||||||
|
Filters come in two worlds:
|
||||||
|
|
||||||
|
- **Books** — rules about books and your reading of them.
|
||||||
|
- **Episodes** — rules about podcast episodes (feeds, downloaded, played).
|
||||||
|
|
||||||
|
Every rule is **a field + a question + your values**. The "Ask" column is the question;
|
||||||
|
the "Your values" column is what you provide. The **default** question is what a new rule
|
||||||
|
starts with.
|
||||||
|
|
||||||
|
## Books
|
||||||
|
|
||||||
|
### Book facts
|
||||||
|
|
||||||
|
**Title** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Subtitle** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Description** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Summary** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Language** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Page count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Chapter count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Total characters** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Community rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date added** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date imported** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Web URL** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Metadata notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**ISBN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**ASIN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**File size** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File availability** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Explicit** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Abridged** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Manually edited** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Cover** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Added by** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
### Comics & manga
|
||||||
|
|
||||||
|
**Series** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Series number** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Series count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Volume** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Format group** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Reflowable** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Fixed layout** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Black & white** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Manga type** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Reading direction** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### My reading
|
||||||
|
|
||||||
|
**Reading status** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
|
||||||
|
**Progress percent** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Last read** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**My rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| unrated | — |
|
||||||
|
|
||||||
|
**Has review** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Highlights** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Note text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Highlight text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Time spent (minutes)** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**Pages read** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| in any | is in at least one of the collections you pick |
|
||||||
|
| in none | — |
|
||||||
|
|
||||||
|
### People
|
||||||
|
|
||||||
|
**Author** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Narrator** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Contributor** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
**Publisher** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Imprint** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Edition** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published year** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published date** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
### Topics
|
||||||
|
|
||||||
|
**Genre** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Tags** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Story arc** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Age rating** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
## Podcast episodes
|
||||||
|
|
||||||
|
### Book facts
|
||||||
|
|
||||||
|
**Title** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Description** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Language** — Type words.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is | matches exactly |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Chapter count** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date added** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Date imported** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File size** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**File availability** — Choose from a fixed list of options.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
**Explicit** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
### Comics & manga
|
||||||
|
|
||||||
|
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### Episodes
|
||||||
|
|
||||||
|
**Feed** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| has | has a value |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Downloaded** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on *(default)* |
|
||||||
|
| is false | is switched off |
|
||||||
|
|
||||||
|
**Played** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is true | is switched on |
|
||||||
|
| is false | is switched off *(default)* |
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | is one of the ones you pick *(default)* |
|
||||||
|
| isn't | matches anything but this |
|
||||||
|
|
||||||
|
### My reading
|
||||||
|
|
||||||
|
**Progress percent** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Last read** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**My rating** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| is | matches exactly |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| unrated | — |
|
||||||
|
|
||||||
|
**Has review** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
|
||||||
|
**Notes** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Highlights** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| has | has a value *(default)* |
|
||||||
|
| missing | is empty or missing |
|
||||||
|
| within the last | — |
|
||||||
|
|
||||||
|
**Note text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Highlight text** — No value needed — the question is the whole rule.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| contains | contains these words *(default)* |
|
||||||
|
| doesn't contain | does NOT contain these words |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Time spent (minutes)** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
|
||||||
|
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| in | is one of the ones you pick (any one is enough) *(default)* |
|
||||||
|
| isn't in | is none of the ones you pick |
|
||||||
|
| in any | is in at least one of the collections you pick |
|
||||||
|
| in none | — |
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
**Published year** — Type a number.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| at least | is at least this *(default)* |
|
||||||
|
| at most | is at most this |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
**Published date** — Type a number of days.
|
||||||
|
|
||||||
|
| Ask | Means |
|
||||||
|
| --- | --- |
|
||||||
|
| within the last | — *(default)* |
|
||||||
|
| between | falls between two numbers |
|
||||||
|
| is empty | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Anything here that doesn't match what the app actually shows is a bug — this page is generated
|
||||||
|
from the app's own menus and a test fails if they ever disagree.*
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Filters — the complete guide
|
||||||
|
|
||||||
|
Filters answer one question: *of everything in my library, show me the ones that match.*
|
||||||
|
|
||||||
|
You'll meet the same filter system in three places:
|
||||||
|
|
||||||
|
| Place | What it's for |
|
||||||
|
| --- | --- |
|
||||||
|
| **The Filters drawer** (All Books / Podcasts pages) | A quick, temporary narrowing of what you see. Save it if you want to keep it. |
|
||||||
|
| **Smart collections** (a collection's **Rules** page) | A filter that *lives* — the collection's contents update automatically forever. |
|
||||||
|
| **Dashboard sections** | The same rules, feeding a shelf on your home screen. |
|
||||||
|
|
||||||
|
One system, one set of skills, three payoffs.
|
||||||
|
|
||||||
|
## Two worlds: Books and Episodes
|
||||||
|
|
||||||
|
Rules about **books** (authors, series, your reading progress) and rules about **podcast episodes**
|
||||||
|
(feeds, downloaded, played) are two different worlds. The field menus only show the fields that
|
||||||
|
make sense where you are — you'll never see a podcast field on the All Books page. The complete
|
||||||
|
list of every field is in [Every filter field](fields.md).
|
||||||
|
|
||||||
|
## Anatomy of a rule
|
||||||
|
|
||||||
|
Every rule is **a field + a question + your values**:
|
||||||
|
|
||||||
|
> **Author** *contains* `asc`
|
||||||
|
|
||||||
|
> **Feed** *in* `7th Son: Book One`
|
||||||
|
|
||||||
|
> **Page count** *at least* `400`
|
||||||
|
|
||||||
|
The field is *what* you're asking about. The question (contains, at least, is empty…) is *how*.
|
||||||
|
Your values are *what counts as a match*. The complete questions per field are in
|
||||||
|
[Every filter field](fields.md).
|
||||||
|
|
||||||
|
## Picking values
|
||||||
|
|
||||||
|
Two kinds of value boxes:
|
||||||
|
|
||||||
|
- **Dropdown boxes** (Feed, Library, Collection, Added by): start typing and real names appear,
|
||||||
|
with live counts of how much matches. Click to pick — each pick becomes a **chip** with a little
|
||||||
|
**×** to remove it. Pick as many as you like; *any one of them* matching is enough.
|
||||||
|
- **Typed boxes** (Author, Title, Page count…): just type. Several words means "contains these
|
||||||
|
words"; number boxes take numbers; date boxes take a number of days.
|
||||||
|
|
||||||
|
The dropdown's counts are **live under your other rules**: if you've already narrowed to
|
||||||
|
*Title contains Chapter*, the feed menu shows how many Chapter-titled episodes each of your feeds
|
||||||
|
has. Rules already picked disappear from their own menu (removing a chip brings them back).
|
||||||
|
|
||||||
|
## Groups: ALL and ANY
|
||||||
|
|
||||||
|
When you have several rules, the group header — **MATCH ALL** or **MATCH ANY** — decides how they
|
||||||
|
combine:
|
||||||
|
|
||||||
|
- **MATCH ALL** — *every* rule must match. (The default, and what you want almost always.)
|
||||||
|
- **MATCH ANY** — *at least one* rule matching is enough. This is how you ask for
|
||||||
|
"Lewis Carroll **or** George Orwell".
|
||||||
|
|
||||||
|
**Click the header to switch it.** It says what it does, and clicking flips it.
|
||||||
|
|
||||||
|
`+ group` adds a nested group with its own header (**GROUP ALL / GROUP ANY**), so bigger shapes
|
||||||
|
build naturally:
|
||||||
|
|
||||||
|
> Match ALL: Author *contains* `Carroll` **AND** GROUP ANY (Title contains `Alice` **OR** Title
|
||||||
|
> contains `Wonderland`)
|
||||||
|
|
||||||
|
For fine-tuning inside an ALL group, the small **AND / OR** chip between two rules mixes them —
|
||||||
|
click `AND` and it becomes `OR`, joining those neighbors together. (The header switch and these
|
||||||
|
chips are two different tools: the header sets the group's overall combine; a chip makes just two
|
||||||
|
neighboring rules special.)
|
||||||
|
|
||||||
|
## The live count
|
||||||
|
|
||||||
|
While you build, the counter at the bottom — *N books match* — runs your rules against the real
|
||||||
|
library. It's the exact number you'll get when you apply.
|
||||||
|
|
||||||
|
## Defaults worth knowing
|
||||||
|
|
||||||
|
- **Podcast listening order**: podcast pages and new podcast collections start at
|
||||||
|
**Feed order (reversed)** — book order, chapter 1 first. Change it per feed or collection from
|
||||||
|
the order dropdown; your choice sticks.
|
||||||
|
- **Hidden libraries** (family lens) never match — a filter can't accidentally surface a library
|
||||||
|
that's hidden from you.
|
||||||
|
- **Episodes that aren't downloaded** stay out of playback chains (they still appear in lists).
|
||||||
|
|
||||||
|
## Keeping a filter
|
||||||
|
|
||||||
|
- **Save** (drawer): name it; it appears in the **Load** menu from then on.
|
||||||
|
- **Apply to Collection** (rules page): the filter becomes the smart collection — live forever.
|
||||||
|
- Filtered views are **shareable**: the URL carries the filter, so a bookmark or sent link opens
|
||||||
|
the same view.
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Frontend Guide
|
|
||||||
|
|
||||||
**Coming Soon**
|
|
||||||
|
|
||||||
This guide will cover:
|
|
||||||
|
|
||||||
- Navigating the Bookhoard web interface
|
|
||||||
- Browsing and searching your library
|
|
||||||
- Managing collections
|
|
||||||
- Viewing reading progress
|
|
||||||
- Using notes and highlights
|
|
||||||
- Personalizing your experience
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_In the meantime, check out our [Device Setup Guides](devices/)_
|
|
||||||
@@ -1,58 +1,43 @@
|
|||||||
# Saving Custom Filters
|
# Browsing your library
|
||||||
|
|
||||||
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
|
Everything starts from the **All Books** page: every book you can see in the
|
||||||
|
current library, as a wall of covers. From there you narrow, sort, and open.
|
||||||
|
|
||||||
## Bookshelf Toolbar
|
## The All Books toolbar
|
||||||
|
|
||||||
The All Books page has a toolbar with:
|
- The **search box** finds titles, authors, and more as you type
|
||||||
|
- The **Sort** dropdown orders the wall: **Title (A-Z)**, **Title (Z-A)**,
|
||||||
|
**Author (A-Z)**, **Date Added**, or **Page Count**
|
||||||
|
- The **Filters** button opens the filter drawer — authors, tags, series,
|
||||||
|
progress, and every other field (the full how-to lives in
|
||||||
|
[Filters — the complete guide](filters/index.md); this page sticks to
|
||||||
|
browsing)
|
||||||
|
- **Save**, **Load**, and **Clear** manage your saved filters: capture the
|
||||||
|
current drawer state under a name, re-apply it any time, and delete ones
|
||||||
|
you no longer want. Saved filters are private to your account.
|
||||||
|
|
||||||
- A **search input** for quick text searches
|
Saved filters pair naturally with [smart collections](collections.md): a
|
||||||
- A **sort** dropdown (title, author, date added, page count)
|
filter answers "what matches right now?", a smart collection keeps matching
|
||||||
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
|
forever.
|
||||||
- **Save**, **Load**, and **Clear** buttons for filter presets
|
|
||||||
|
|
||||||
## How to Save a Filter
|
## Opening a book
|
||||||
|
|
||||||
1. Navigate to the **All Books** page
|
Click any cover to open the book's detail page — description, files, your
|
||||||
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
|
progress, actions to read or listen, and the places to edit metadata or fix
|
||||||
3. Click the **Save** button in the toolbar
|
what a scan guessed wrong. The detail page is the hub: series links,
|
||||||
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
reviews, bookmarks, and the edit modal all live there.
|
||||||
5. Click **Save**
|
|
||||||
|
|
||||||
## Loading Saved Filters
|
## Series
|
||||||
|
|
||||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
A book's detail page links to its **series page** — every book in that
|
||||||
2. Click a filter's name to apply it
|
series, in order, for the same library. Series are one of the fields the
|
||||||
3. The filter values are applied instantly, without a page reload
|
filter drawer can match on too.
|
||||||
|
|
||||||
## Managing Saved Filters
|
## Podcasts are separate
|
||||||
|
|
||||||
**Delete a filter:**
|
Podcast feeds and episodes live on the **Podcasts** page, not the book wall —
|
||||||
|
see [Podcasts](podcasts.md).
|
||||||
|
|
||||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
*Finding your way back:* **All Books** is the wall-of-covers page with the
|
||||||
2. Click the trash icon next to the filter you want to remove
|
**Filters** button; the same toolbar pattern is what you'll see on the
|
||||||
3. Confirm deletion
|
Podcasts page.
|
||||||
|
|
||||||
**Filter Privacy:**
|
|
||||||
|
|
||||||
Saved filters are **private to your account**. Other users cannot see or modify your filters.
|
|
||||||
|
|
||||||
## Common Use Cases
|
|
||||||
|
|
||||||
**Reading by Genre:**
|
|
||||||
|
|
||||||
1. Filter by tag: "Science Fiction"
|
|
||||||
2. Save as "Sci-Fi Books"
|
|
||||||
3. Quickly access all your sci-fi collection anytime
|
|
||||||
|
|
||||||
**Author Collections:**
|
|
||||||
|
|
||||||
1. Filter by author: "Isaac Asimov"
|
|
||||||
2. Save as "Asimov Books"
|
|
||||||
3. Switch between different author collections instantly
|
|
||||||
|
|
||||||
**Series Tracking:**
|
|
||||||
|
|
||||||
1. Filter by series: "Foundation"
|
|
||||||
2. Save as "Foundation Series"
|
|
||||||
3. Track your progress through a series
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Podcasts
|
||||||
|
|
||||||
|
Bookhoard is not just books: it also runs your podcast subscriptions. You
|
||||||
|
subscribe to feeds, episodes download for offline listening, and you can
|
||||||
|
play a run of episodes back-to-back — a feed, or a hand-picked sequence.
|
||||||
|
|
||||||
|
## The Podcasts page
|
||||||
|
|
||||||
|
Open **Podcasts** from the sidebar. The page is your subscriptions:
|
||||||
|
|
||||||
|
- **Continue** — the episode you were in the middle of, ready to resume
|
||||||
|
- **All Feeds** — every feed you subscribe to
|
||||||
|
- **Search** narrows your feeds and episodes
|
||||||
|
- Each feed's page holds its episodes, with **Download All** for grabbing
|
||||||
|
everything at once
|
||||||
|
|
||||||
|
Subscriptions live in your **Podcasts library** (feeds manage it themselves —
|
||||||
|
you won't see it in the regular library switcher, and it never mixes into
|
||||||
|
the book wall on **All Books**).
|
||||||
|
|
||||||
|
## Subscribing to a feed
|
||||||
|
|
||||||
|
1. Click **Add Feed** on the Podcasts page
|
||||||
|
2. In the **Add Podcast Feed** panel, paste the show's **RSS feed URL**
|
||||||
|
3. Choose the **Library** the podcast belongs to
|
||||||
|
4. Optionally: check **Download all episodes now** to grab the back catalog
|
||||||
|
immediately, and pick the **Download order** for new episodes — **Feed
|
||||||
|
order**, **Feed order (reversed)**, **Oldest first (pub date)**, or
|
||||||
|
**Newest first (pub date)**
|
||||||
|
5. Click **Add & Subscribe**
|
||||||
|
|
||||||
|
From then on Bookhoard watches the feed: new episodes appear as the show
|
||||||
|
publishes them, downloaded according to the order you picked.
|
||||||
|
|
||||||
|
## Playing episodes
|
||||||
|
|
||||||
|
Click an episode to open the audio player. The player keeps your place,
|
||||||
|
supports bookmarks, a chapter list where the show provides chapters, and
|
||||||
|
playback speed — the same speed and resume-rewind defaults you set on your
|
||||||
|
[profile](profile-guide.md) apply here.
|
||||||
|
|
||||||
|
To play many episodes in a row, use a collection: episode collections have a
|
||||||
|
**Play as Sequence** button and their own listening order — see
|
||||||
|
[Collections](collections.md).
|
||||||
|
|
||||||
|
*Finding your way back:* **Podcasts** is the sidebar page whose header reads
|
||||||
|
"Your subscriptions" — the only page with the **Add Feed** button.
|
||||||
+37
-87
@@ -1,99 +1,49 @@
|
|||||||
# Profile Management Guide
|
# Your profile
|
||||||
|
|
||||||
## Updating Your Profile
|
Your profile page holds everything that is about *you* rather than your
|
||||||
|
books: account details, playback preferences, your password, and — if you
|
||||||
|
ever need it — account deletion.
|
||||||
|
|
||||||
Your profile contains your account information and preferences.
|
Open it from the sidebar: click your **username** at the bottom, then
|
||||||
|
**Profile**.
|
||||||
|
|
||||||
### How to Update
|
## Account information
|
||||||
|
|
||||||
1. Click on your **username** at the bottom of the sidebar to expand the account menu
|
| Field | What it does |
|
||||||
2. Select **Profile**
|
| --- | --- |
|
||||||
3. Edit any fields in the "Account Information" section
|
| **Username** | Your login name — must stay unique |
|
||||||
4. Click **Save Changes**
|
| **Email Address** | Must stay unique |
|
||||||
5. Changes take effect immediately
|
| **First Name / Last Name** | Optional display names |
|
||||||
|
| **Theme** | A quick theme picker (seven favorites — see below for the full set) |
|
||||||
|
| **Default Playback Speed** | The speed audiobook playback starts at |
|
||||||
|
| **Resume Rewind** | Jumps back a few seconds when you resume playback (**Off (exact resume)**, 5, or 10 seconds) |
|
||||||
|
| **Keep Screen Awake** | Stops the screen sleeping during playback |
|
||||||
|
| **Timezone** | All times the server shows you (activity, stats, history) are rendered in this zone |
|
||||||
|
|
||||||
### Fields You Can Update
|
Changes save from the form's save button and apply immediately.
|
||||||
|
|
||||||
- **Username** - Your login name (must be unique)
|
About the two theme doors: the profile's **Theme** select offers seven
|
||||||
- **Email** - Your email address (must be unique)
|
quick picks. The sidebar's **Appearance** panel offers the full collection —
|
||||||
- **First Name** - Optional display name
|
[Themes and Wood Paneling](themes.md) covers it, including the bookshelf
|
||||||
- **Last Name** - Optional display name
|
wood textures, which are per-browser and not part of your account.
|
||||||
- **Theme** - Your preferred color scheme
|
|
||||||
|
|
||||||
## Changing Your Password
|
## Changing your password
|
||||||
|
|
||||||
Regular password changes are recommended for account security.
|
On the same page, the **Change Password** section asks for your **Current
|
||||||
|
Password**, a **New Password**, and **Confirm New Password**. The
|
||||||
|
requirements are checked as you type; click **Update Password** to save.
|
||||||
|
|
||||||
### How to Change
|
## Deleting your account
|
||||||
|
|
||||||
1. Go to **Profile** page
|
The **Danger Zone** at the bottom holds **Remove My Account**. Deletion
|
||||||
2. Scroll to "Change Password" section
|
removes your account and everything personal to it — profile, reading
|
||||||
3. Enter your **current password**
|
progress and history, device registrations, your collections — and keeps the
|
||||||
4. Enter your **new password**
|
libraries themselves, since books belong to the library, not to you. It
|
||||||
5. **Confirm** your new password
|
cannot be undone.
|
||||||
6. Click **Update Password**
|
|
||||||
|
|
||||||
### Password Requirements
|
The last administrator cannot delete their own account — one admin must
|
||||||
|
always remain.
|
||||||
|
|
||||||
- Minimum 8 characters
|
Administrators can also edit other users' profiles (including resetting a
|
||||||
- Must contain uppercase, lowercase, number, and special character
|
password without the current one) from the admin area; that is covered with
|
||||||
- Must match confirmation field
|
the rest of the admin docs.
|
||||||
- Current password must be correct
|
|
||||||
|
|
||||||
## Deleting Your Account
|
|
||||||
|
|
||||||
**⚠️ WARNING:** This action cannot be undone.
|
|
||||||
|
|
||||||
### What Gets Deleted
|
|
||||||
|
|
||||||
When you delete your account:
|
|
||||||
|
|
||||||
- Your profile information
|
|
||||||
- Reading progress and history
|
|
||||||
- Device connections
|
|
||||||
- Collection preferences
|
|
||||||
- All personal data
|
|
||||||
|
|
||||||
### What Stays
|
|
||||||
|
|
||||||
- Library books (media belongs to the library, not you)
|
|
||||||
- System settings
|
|
||||||
- Other users' accounts
|
|
||||||
|
|
||||||
### How to Delete
|
|
||||||
|
|
||||||
1. Go to **Profile** page
|
|
||||||
2. Scroll to "Danger Zone" (bottom of page)
|
|
||||||
3. Click **Remove My Account**
|
|
||||||
4. Confirm the deletion prompt
|
|
||||||
|
|
||||||
**Note:** If you're the last admin, you cannot delete your account for security reasons.
|
|
||||||
|
|
||||||
## Theme Options
|
|
||||||
|
|
||||||
Personalize your reading experience with different color themes.
|
|
||||||
|
|
||||||
### Quick Theme Switch
|
|
||||||
|
|
||||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
|
||||||
2. Select a theme from the list; your active theme is marked with a checkmark
|
|
||||||
3. Changes apply instantly
|
|
||||||
|
|
||||||
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
|
|
||||||
|
|
||||||
### Available Themes
|
|
||||||
|
|
||||||
- **Tokyo Night** (default) - Blue/purple accents
|
|
||||||
- **Dracula** - Purple/pink tones
|
|
||||||
- **Nord** - Arctic, bluish-gray
|
|
||||||
- **Solarized Dark** - Warm, precise contrast
|
|
||||||
- **Monokai** - Classic vibrant colors
|
|
||||||
- **One Dark Pro** - Atom editor inspired
|
|
||||||
- **Material Dark** - Google Material Design
|
|
||||||
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
|
|
||||||
|
|
||||||
## For Admin Users
|
|
||||||
|
|
||||||
If you're an administrator, you can also manage other users' profiles from the admin panel.
|
|
||||||
|
|
||||||
See [Admin User Management](admin-guide.md) for details.
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Bookhoard's built-in web reader works in any modern browser — desktop or
|
Bookhoard's built-in web reader works in any modern browser — desktop or
|
||||||
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
||||||
with the Android app and KOReader.
|
with the Android app and KOReader. Audiobooks get their own player, covered
|
||||||
|
at the end of this guide.
|
||||||
|
|
||||||
## Opening a Book
|
## Opening a Book
|
||||||
|
|
||||||
@@ -79,3 +80,25 @@ to jump to it.
|
|||||||
Position, highlights, bookmarks, and notes sync automatically through the
|
Position, highlights, bookmarks, and notes sync automatically through the
|
||||||
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
||||||
like — the reader resumes exactly where you left off on any device.
|
like — the reader resumes exactly where you left off on any device.
|
||||||
|
|
||||||
|
## Listening to audiobooks and podcast episodes
|
||||||
|
|
||||||
|
Books with audio files, and podcast episodes, open in the audio player
|
||||||
|
instead of the page reader. It is one player for both:
|
||||||
|
|
||||||
|
- The **seek bar** is your position; drag it, or use the **previous /
|
||||||
|
next episode** controls when you are playing a sequence
|
||||||
|
- **Bookmarks** opens a panel of your markers for this title — tap one to
|
||||||
|
jump back
|
||||||
|
- **Chapters** opens the chapter list (shows "currently chapter N of M");
|
||||||
|
audiobooks with embedded chapters and podcast shows with chapter marks
|
||||||
|
both land here
|
||||||
|
- **Player settings** holds the per-player preferences: **Autoplay on
|
||||||
|
open**, **Default playback speed**, and **Resume rewind** (a few seconds
|
||||||
|
back when you resume — the defaults come from your
|
||||||
|
[profile](profile-guide.md) and this panel overrides them per player)
|
||||||
|
- The **Shortcuts & interactions** panel (the **?** control) lists every
|
||||||
|
keyboard shortcut
|
||||||
|
|
||||||
|
Progress, bookmarks, and your speed carry across devices through the same
|
||||||
|
sync as everything else.
|
||||||
@@ -35,3 +35,12 @@ You can combine tags with other filters:
|
|||||||
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
||||||
- Best matching tags appear first in results
|
- Best matching tags appear first in results
|
||||||
- Autocomplete shows existing tags as you type with relevance scores
|
- Autocomplete shows existing tags as you type with relevance scores
|
||||||
|
|
||||||
|
|
||||||
|
## The complete filter guide
|
||||||
|
|
||||||
|
The filters described above are one system that also powers smart collections and dashboard
|
||||||
|
sections — groups with ALL/ANY, saved filters, and every field listed. The full manual lives in
|
||||||
|
[this folder](filters/index.md): the [guide](filters/index.md), the
|
||||||
|
[cookbook](filters/cookbook.md) of ready-made recipes, and the generated
|
||||||
|
[field reference](filters/fields.md).
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Settings Guide
|
|
||||||
|
|
||||||
**Coming Soon**
|
|
||||||
|
|
||||||
This guide will cover:
|
|
||||||
|
|
||||||
- Account settings
|
|
||||||
- Display preferences (themes, fonts)
|
|
||||||
- Sync configuration
|
|
||||||
- Device management
|
|
||||||
- Privacy options
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_In the meantime, check out our [Device Setup Guides](devices/)_
|
|
||||||
+99
-555
@@ -1,591 +1,135 @@
|
|||||||
# Bookhoard Universal Sync - User Guide
|
# Universal Sync — how your devices stay in step
|
||||||
|
|
||||||
## Table of Contents
|
Sync is the promise that makes Bookhoard more than a web page: read on
|
||||||
|
whatever is in your hands, and every other device catches up. One server
|
||||||
|
holds the truth for each book — your position, bookmarks, highlights, and
|
||||||
|
notes — and every registered device pushes and pulls against it.
|
||||||
|
|
||||||
1. [What is Universal Sync?](#what-is-universal-sync)
|
## Supported devices today
|
||||||
2. [Supported Devices](#supported-devices)
|
|
||||||
3. [Getting Started](#getting-started)
|
|
||||||
4. [Device Setup](#device-setup)
|
|
||||||
5. [Using Sync Features](#using-sync-features)
|
|
||||||
6. [Book Matching](#book-matching)
|
|
||||||
7. [Managing Conflicts](#managing-conflicts)
|
|
||||||
8. [Best Practices](#best-practices)
|
|
||||||
|
|
||||||
---
|
| Platform | Status | How it syncs |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Web browser** | ✅ Fully supported | Live, as you read — the reader saves position and annotations continuously |
|
||||||
|
| **Android app** | ✅ Fully supported | The native client: progress, highlights, bookmarks, notes; reads offline and queues changes until you are back online |
|
||||||
|
| **KOReader** (Kindle, Kobo, PocketBook) | ✅ Fully supported | The Bookhoard KOReader plugin, over Wi-Fi |
|
||||||
|
| **Kobo, stock firmware** | 🚧 Server support complete | The native Kobo protocol is implemented and documented server-side; device-side enablement is the remaining piece — [Kobo Setup](devices/kobo-setup.md) has the details |
|
||||||
|
|
||||||
## What is Universal Sync?
|
Kindle and reMarkable are under consideration; KOReader already covers
|
||||||
|
several of those devices today.
|
||||||
|
|
||||||
**Universal Sync** is Bookhoard's cross-platform synchronization system that keeps your reading progress, highlights, and notes in sync across all your devices - automatically and in real-time.
|
## Getting a device into the circle
|
||||||
|
|
||||||
### Key Features
|
1. Connect the device to your server (each guide above walks its own path)
|
||||||
|
2. Approve the pending registration on the **Devices** page — the Android
|
||||||
|
app registers itself on first login; KOReader asks from its plugin
|
||||||
|
3. Done. Sync starts as soon as a device is approved
|
||||||
|
|
||||||
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
|
The **Devices** page is mission control: see every registered device and
|
||||||
|
when it was last seen, remove a device (revoking its access instantly), and
|
||||||
|
work through **unlinked books** — device files that could not be matched to
|
||||||
|
a library book yet.
|
||||||
|
|
||||||
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap
|
## Book matching
|
||||||
|
|
||||||
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
|
When a device reports a book, Bookhoard tries to match it to your library
|
||||||
|
by fingerprint, strongest first:
|
||||||
|
|
||||||
⚡ **Real-Time Updates** - See your progress update across all devices instantly
|
1. **Bookhoard UUID** — 100% confidence
|
||||||
|
2. **OPF UUID** — 95%
|
||||||
|
3. **SHA-256 hash** — 90%
|
||||||
|
4. **OPF identifier** — 85%
|
||||||
|
5. **ISBN / ASIN** — 80%
|
||||||
|
6. **Title + Author + file size** — 50%
|
||||||
|
7. **Title only** — 30% (last resort, only tried when nothing else matched)
|
||||||
|
|
||||||
🔒 **Secure** - No passwords on devices, web-based authentication only
|
Books that match automatically never bother you. The ones that can't
|
||||||
|
(side-loaded files, converted formats like EPUB → KEPUB, mismatched
|
||||||
|
metadata) land in the device's **unlinked books** list, where you can:
|
||||||
|
|
||||||
📴 **Offline Support** - Queue changes when offline, sync when reconnected
|
- **Auto-link** everything above a confidence threshold
|
||||||
|
- Review **suggestions** first, then link
|
||||||
|
- **Link by hand** — pick the right library book yourself
|
||||||
|
|
||||||
---
|
## What syncs
|
||||||
|
|
||||||
## Supported Devices
|
**Reading progress** — page, percentage, chapter position, EPUB CFI, last
|
||||||
|
read time. EPUBs sync by percentage and CFI, PDFs and comics by page, so
|
||||||
|
the same book lines up on devices with different page counts.
|
||||||
|
|
||||||
### Currently Supported ✅
|
**Highlights and notes** — the highlighted text, your note, its color, and
|
||||||
|
where it sits. Locations are stored in several forms at once (page, EPUB
|
||||||
|
CFI, percentage, character offset), which is why a highlight made on your
|
||||||
|
phone lands in the right place on an e-reader with different pagination.
|
||||||
|
|
||||||
| Platform | Status | Sync Method | Notes |
|
**Bookmarks** — location, title, and when you made them.
|
||||||
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
|
|
||||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
|
||||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
|
|
||||||
|
|
||||||
### Coming Soon 🚧
|
Everything above is per-user and private: your devices see your data
|
||||||
|
because you approved them, other users see theirs, and revoking a device
|
||||||
|
ends its access on the spot.
|
||||||
|
|
||||||
| Platform | Status |
|
## Conflicts
|
||||||
| --------------------- | ------------------------------------------------------------- |
|
|
||||||
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
|
|
||||||
| **Mobile Apps** | Android/iOS apps coming later |
|
|
||||||
|
|
||||||
### On the Roadmap 🔭
|
If the same book is read on two devices close together, Bookhoard does not
|
||||||
|
silently pick a winner — it records a **sync conflict** and shows it to
|
||||||
|
you:
|
||||||
|
|
||||||
| Platform | Status |
|
- The book's detail page grows a **Sync Progress** button (with a
|
||||||
| --------------------- | ---------------------------------------- |
|
"Progress conflict detected" notice)
|
||||||
| **Kindle Devices** | Under consideration (no date yet) |
|
- The **Conflicts** page (`/conflicts`) lists everything unresolved
|
||||||
| **Remarkable Tablet** | Under consideration (no date yet) |
|
|
||||||
|
|
||||||
---
|
Resolving means choosing a strategy: keep one side's progress, keep the
|
||||||
|
server's current value, or let the **furthest progress win**. Until you
|
||||||
|
resolve, the conflict stays listed — nothing is lost, it just waits for
|
||||||
|
your call.
|
||||||
|
|
||||||
## Getting Started
|
## Offline reading
|
||||||
|
|
||||||
### Prerequisites
|
Devices keep working without the server. The Android app reads fully
|
||||||
|
offline and queues your changes, draining the queue when you reconnect.
|
||||||
|
E-readers push their progress on the next connection. The server holds a
|
||||||
|
per-device queue for inbound work and retries failures automatically; if
|
||||||
|
items pile up (a device offline for weeks), an admin can retry or clear a
|
||||||
|
device's queue from the server side.
|
||||||
|
|
||||||
1. **Bookhoard Server** - Self-hosted instance running and accessible
|
## Reading history
|
||||||
2. **Network Connection** - Devices must be able to reach your Bookhoard server
|
|
||||||
3. **User Account** - Created and logged in to Bookhoard web interface
|
|
||||||
|
|
||||||
### Quick Start
|
Every reading session is logged — when, how long, on which device — and
|
||||||
|
feeds your [dashboard](dashboard.md) and stats. Your history is visible on
|
||||||
1. **Set up your first device** - See [Device Setup](#device-setup) below
|
the **Progress** page and on each book's detail page, and it is yours
|
||||||
2. **Start reading** - Open any book in the web interface or on your device
|
alone.
|
||||||
3. **Turn pages** - Progress syncs automatically
|
|
||||||
4. **Switch devices** - Pick up any other device - your progress is there!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Device Setup
|
|
||||||
|
|
||||||
For detailed device configuration instructions, see the appropriate setup guide:
|
|
||||||
|
|
||||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
|
|
||||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
|
|
||||||
|
|
||||||
### Quick Overview
|
|
||||||
|
|
||||||
**Registration Process** (KOReader):
|
|
||||||
|
|
||||||
1. Install the Bookhoard plugin and enter your server URL in KOReader
|
|
||||||
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
|
|
||||||
3. That's it — sync starts automatically once approved
|
|
||||||
|
|
||||||
**Device Management**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Devices page (sidebar navigation)
|
|
||||||
```
|
|
||||||
|
|
||||||
You can:
|
|
||||||
|
|
||||||
- View all your registered devices
|
|
||||||
- See last sync time and status
|
|
||||||
- Disable or remove devices
|
|
||||||
- View unlinked books that need matching
|
|
||||||
- Manage file aliases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Book Matching
|
|
||||||
|
|
||||||
### What is Book Matching?
|
|
||||||
|
|
||||||
When devices sync books, Bookhoard tries to automatically match them using:
|
|
||||||
|
|
||||||
- **SHA-256 hash** (most reliable) - Content-based fingerprint
|
|
||||||
- **ISBN** - Standard book identifier
|
|
||||||
- **UUID** - Unique identifier from EPUB metadata
|
|
||||||
- **Title + Author** (fallback) - Last resort matching
|
|
||||||
|
|
||||||
### Unlinked Books
|
|
||||||
|
|
||||||
Sometimes a book on your device can't be automatically matched to your library. This happens when:
|
|
||||||
|
|
||||||
- The book was side-loaded (not downloaded via Bookhoard)
|
|
||||||
- The file format was converted (EPUB → KEPUB)
|
|
||||||
- The metadata doesn't match exactly
|
|
||||||
|
|
||||||
### Viewing Unlinked Books
|
|
||||||
|
|
||||||
```
|
|
||||||
Devices page → select device → unlinked books
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resolving Unlinked Books
|
|
||||||
|
|
||||||
1. **Auto-Link** - Automatically link books with 80%+ confidence
|
|
||||||
2. **Get Suggestions** - Review potential matches before linking
|
|
||||||
3. **Manual Link** - Choose the correct match yourself
|
|
||||||
|
|
||||||
### Book Matching Priority
|
|
||||||
|
|
||||||
1. **Bookhoard UUID** - 100% confidence
|
|
||||||
2. **OPF UUID** - 95% confidence
|
|
||||||
3. **SHA-256 hash** - 90% confidence
|
|
||||||
4. **OPF identifier** - 85% confidence
|
|
||||||
5. **ISBN/ASIN** - 80% confidence
|
|
||||||
6. **Title + Author + File Size** - 50% confidence
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Managing Conflicts
|
|
||||||
|
|
||||||
### Reading Progress
|
|
||||||
|
|
||||||
**What Syncs**:
|
|
||||||
|
|
||||||
- Current page number
|
|
||||||
- Reading percentage
|
|
||||||
- Chapter progress
|
|
||||||
- EPUB CFI (for EPUB files)
|
|
||||||
- Last read timestamp
|
|
||||||
- Reading position (viewport, zoom, scroll)
|
|
||||||
|
|
||||||
**How It Works**:
|
|
||||||
|
|
||||||
```
|
|
||||||
You turn page → Device sends progress → Server updates database
|
|
||||||
↓
|
|
||||||
Broadcasts to all your devices
|
|
||||||
↓
|
|
||||||
Other devices update their display
|
|
||||||
```
|
|
||||||
|
|
||||||
**Supported Progress Types**:
|
|
||||||
|
|
||||||
- **EPUB/MOBI**: Percentage + EPUB CFI + Chapter
|
|
||||||
- **PDF/DJVU**: Page number + Viewport position
|
|
||||||
- **CBZ/CBR**: Page number + Panel coordinates
|
|
||||||
|
|
||||||
### Highlights & Notes
|
|
||||||
|
|
||||||
**What Syncs**:
|
|
||||||
|
|
||||||
- Highlighted text
|
|
||||||
- Notes and annotations
|
|
||||||
- Bookmark locations
|
|
||||||
- Colors and formatting
|
|
||||||
- Chapter/paragraph references
|
|
||||||
|
|
||||||
**Universal Location References**:
|
|
||||||
All highlights are stored with multiple location types:
|
|
||||||
|
|
||||||
- Page:offset (traditional)
|
|
||||||
- EPUB CFI (EPUB files)
|
|
||||||
- Percentage (0-100%)
|
|
||||||
- Character offset (exact position)
|
|
||||||
- Chapter references
|
|
||||||
|
|
||||||
This ensures your highlights work across all devices, even with different page counts!
|
|
||||||
|
|
||||||
### Bookmarks
|
|
||||||
|
|
||||||
**What Syncs**:
|
|
||||||
|
|
||||||
- Bookmark locations
|
|
||||||
- Bookmark titles
|
|
||||||
- Date created
|
|
||||||
- Reading position context
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sync Modes
|
|
||||||
|
|
||||||
### Immediate Mode (Default)
|
|
||||||
|
|
||||||
**Best For**: Normal reading, page turns
|
|
||||||
|
|
||||||
**Behavior**:
|
|
||||||
|
|
||||||
- Syncs every page turn
|
|
||||||
- Real-time updates
|
|
||||||
- Low latency
|
|
||||||
- Higher bandwidth usage
|
|
||||||
|
|
||||||
**Recommended Settings**:
|
|
||||||
|
|
||||||
- Auto-sync: ON
|
|
||||||
- Sync frequency: Every page turn
|
|
||||||
|
|
||||||
### Checkpoint Mode
|
|
||||||
|
|
||||||
**Best For**: Slow connections, battery saving
|
|
||||||
|
|
||||||
**Behavior**:
|
|
||||||
|
|
||||||
- Batches changes
|
|
||||||
- Syncs every 5 minutes or when connection allows
|
|
||||||
- Lower bandwidth
|
|
||||||
- Better for offline reading
|
|
||||||
|
|
||||||
**Recommended Settings**:
|
|
||||||
|
|
||||||
- Auto-sync: ON
|
|
||||||
- Sync frequency: Checkpoint mode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Offline Support
|
|
||||||
|
|
||||||
### How Offline Sync Works
|
|
||||||
|
|
||||||
1. **Offline Reading**:
|
|
||||||
- Read normally without connection
|
|
||||||
- All progress tracked locally
|
|
||||||
|
|
||||||
2. **Queue Changes**:
|
|
||||||
- Progress changes queued automatically
|
|
||||||
- Annotations saved locally
|
|
||||||
- Up to 100 items in offline queue
|
|
||||||
|
|
||||||
3. **Reconnection**:
|
|
||||||
- Connect to Wi-Fi
|
|
||||||
- Queue processes automatically
|
|
||||||
- Priority items first (book completion, notes)
|
|
||||||
- All devices updated
|
|
||||||
|
|
||||||
### Offline Indicators
|
|
||||||
|
|
||||||
**In Web Interface**:
|
|
||||||
|
|
||||||
- Yellow status icon: Device offline
|
|
||||||
- Last seen timestamp
|
|
||||||
- "Pending sync" badge on books
|
|
||||||
|
|
||||||
**On Devices**:
|
|
||||||
|
|
||||||
- Sync icon: Gray = offline
|
|
||||||
- Sync icon: Blue = syncing
|
|
||||||
- Sync icon: Green = synced
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Sync Not Working
|
**Progress not updating**
|
||||||
|
Check the device is still on the **Devices** page and was approved; check
|
||||||
|
the server URL in the device's settings; confirm the device can reach the
|
||||||
|
server (open the server URL in the device's browser as a test).
|
||||||
|
|
||||||
**Symptoms**: Progress not updating across devices
|
**"Device not found"**
|
||||||
|
The device was removed or its token revoked — register it again and
|
||||||
|
approve it.
|
||||||
|
|
||||||
**Solutions**:
|
**A conflict keeps coming back**
|
||||||
|
Two devices are genuinely fighting over the same book. Decide which device
|
||||||
|
is the reader for that book, resolve with a strategy above, and avoid
|
||||||
|
simultaneous reading of the same title.
|
||||||
|
|
||||||
1. Check device is online: Devices page (sidebar navigation)
|
**High battery or data use**
|
||||||
2. Verify sync is enabled for the device
|
That is the cost of continuous sync on a chatty device; e-reader plugins
|
||||||
3. Check sync URL is correct
|
let you space out their sync checks from the plugin's own settings.
|
||||||
4. Ensure device has network connection
|
|
||||||
5. Try manual sync: Open book → Menu → Sync Now
|
|
||||||
|
|
||||||
### "Device Not Found" Error
|
## Device limits
|
||||||
|
|
||||||
**Cause**: Device not registered or authorization revoked
|
Each account has a device limit — **10 by default**, and administrators can
|
||||||
|
raise or lower it per user. Removing a device you no longer use frees a
|
||||||
**Solutions**:
|
slot.
|
||||||
|
|
||||||
1. Re-register the device
|
|
||||||
2. Check device hasn't been removed
|
|
||||||
3. Verify correct device type selected
|
|
||||||
|
|
||||||
### "Rate Limit Exceeded" Error
|
|
||||||
|
|
||||||
**Cause**: Too many sync requests
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Wait a few seconds
|
|
||||||
2. Switch to checkpoint mode
|
|
||||||
3. Contact admin to increase limits
|
|
||||||
|
|
||||||
### Conflicts Detected
|
|
||||||
|
|
||||||
**Symptoms**: "Sync conflict" notification
|
|
||||||
|
|
||||||
**Cause**: Same book being read on multiple devices simultaneously
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Open the book's detail page and click **Sync Progress**, or go to the Conflicts page (`/conflicts`)
|
|
||||||
2. Review both device progress
|
|
||||||
3. Choose which device's progress to keep
|
|
||||||
4. Or choose "Merge" (keeps furthest progress)
|
|
||||||
|
|
||||||
### High Battery Usage
|
|
||||||
|
|
||||||
**Cause**: Immediate sync mode with frequent page turns
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Switch to checkpoint mode
|
|
||||||
2. Increase sync interval
|
|
||||||
3. Sync less frequently
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Best Practices
|
More detail on any device lives in its guide:
|
||||||
|
[Android](devices/android-app.md) ·
|
||||||
### For Optimal Performance
|
[KOReader](devices/koreader-setup.md) ·
|
||||||
|
[Kobo](devices/kobo-setup.md) ·
|
||||||
✅ **DO**:
|
[Calibre import](calibre-integration.md) ·
|
||||||
|
and the [filter rules](filters/index.md) that power matching-adjacent
|
||||||
- Use checkpoint mode when on slow connections
|
shelves like [smart collections](collections.md).
|
||||||
- Keep device firmware updated
|
|
||||||
- Use Wi-Fi when available
|
|
||||||
- Approve only devices you own
|
|
||||||
- Regularly check conflict resolution
|
|
||||||
|
|
||||||
❌ **DON'T**:
|
|
||||||
|
|
||||||
- Read same book on multiple devices simultaneously
|
|
||||||
- Ignore conflict notifications
|
|
||||||
- Register public/shared devices
|
|
||||||
- Exceed device limits (max 10 per user)
|
|
||||||
|
|
||||||
### Organizing Your Library
|
|
||||||
|
|
||||||
**For Best Sync Experience**:
|
|
||||||
|
|
||||||
- Use consistent metadata (titles, authors)
|
|
||||||
- Avoid duplicate books in library
|
|
||||||
- Match files by ISBN when possible
|
|
||||||
- Use clean file structure
|
|
||||||
|
|
||||||
### Managing Multiple Devices
|
|
||||||
|
|
||||||
**Recommended Setup**:
|
|
||||||
|
|
||||||
- **Primary Device**: KOReader on e-reader
|
|
||||||
- **Secondary Device**: Web browser (work/home)
|
|
||||||
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
|
|
||||||
|
|
||||||
**Sync Strategy**:
|
|
||||||
|
|
||||||
1. Read mainly on primary device
|
|
||||||
2. Check progress on web/secondary devices
|
|
||||||
3. Let auto-sync handle updates
|
|
||||||
4. Resolve conflicts promptly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### Conflict Resolution
|
|
||||||
|
|
||||||
**Automatic Resolution**:
|
|
||||||
|
|
||||||
- Most recent progress wins
|
|
||||||
- Timestamp-based comparison
|
|
||||||
- 5-minute window for conflict detection
|
|
||||||
|
|
||||||
**Manual Resolution**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Book detail → Sync Progress → choose winner
|
|
||||||
```
|
|
||||||
|
|
||||||
**Options**:
|
|
||||||
|
|
||||||
- **Keep Device A**: Use this device's progress
|
|
||||||
- **Keep Device B**: Use other device's progress
|
|
||||||
- **Merge**: Keep furthest progress (combination)
|
|
||||||
- **Auto-Resolve Future**: Always prefer this device
|
|
||||||
|
|
||||||
### Sync Queue Management
|
|
||||||
|
|
||||||
**View Queue Status**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Devices page → sync queue section
|
|
||||||
```
|
|
||||||
|
|
||||||
**Queue Stats**:
|
|
||||||
|
|
||||||
- Pending: Waiting to sync
|
|
||||||
- Processing: Currently syncing
|
|
||||||
- Failed: Retry scheduled
|
|
||||||
- Completed: Successfully synced
|
|
||||||
|
|
||||||
**Manual Actions**:
|
|
||||||
|
|
||||||
- **Retry All**: Retry all failed items
|
|
||||||
- **Clear Queue**: Remove all pending items
|
|
||||||
- **Priority Sync**: Sync specific book immediately
|
|
||||||
|
|
||||||
### Reading History
|
|
||||||
|
|
||||||
**Automatic Tracking**:
|
|
||||||
|
|
||||||
- Every sync session logged
|
|
||||||
- Time spent reading calculated
|
|
||||||
- Pages read tracked
|
|
||||||
- Device used recorded
|
|
||||||
|
|
||||||
**View History**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Progress page (sidebar navigation), or the book's detail page
|
|
||||||
```
|
|
||||||
|
|
||||||
**Privacy**:
|
|
||||||
|
|
||||||
- Only you can see your history
|
|
||||||
- History kept for 365 days
|
|
||||||
- Exportable for backup
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security & Privacy
|
|
||||||
|
|
||||||
### Device Authentication
|
|
||||||
|
|
||||||
**Secure by Design**:
|
|
||||||
|
|
||||||
- ✅ No passwords stored on devices
|
|
||||||
- ✅ Web-based approval required
|
|
||||||
- ✅ Unique tokens per device
|
|
||||||
- ✅ Revocable at any time
|
|
||||||
- ✅ Token encryption in transit
|
|
||||||
|
|
||||||
### Data Protection
|
|
||||||
|
|
||||||
**What We Store**:
|
|
||||||
|
|
||||||
- Reading progress (page, percentage)
|
|
||||||
- Highlights and notes
|
|
||||||
- Device identifiers
|
|
||||||
- Sync timestamps
|
|
||||||
|
|
||||||
**What We DON'T Store**:
|
|
||||||
|
|
||||||
- Passwords on devices
|
|
||||||
- Reading content (your books)
|
|
||||||
- Unencrypted personal data
|
|
||||||
- Location data (GPS)
|
|
||||||
|
|
||||||
### Access Control
|
|
||||||
|
|
||||||
**Your Data**:
|
|
||||||
|
|
||||||
- Only you can see your progress
|
|
||||||
- Admins cannot read your annotations
|
|
||||||
- Shared only with devices you approve
|
|
||||||
|
|
||||||
**Device Access**:
|
|
||||||
|
|
||||||
- Each device sees only your libraries
|
|
||||||
- Devices cannot access other users
|
|
||||||
- Revoking removes all access
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
### For Faster Sync
|
|
||||||
|
|
||||||
1. **Use 5GHz Wi-Fi** - Faster than 2.4GHz
|
|
||||||
2. **Keep server nearby** - Low latency = better sync
|
|
||||||
3. **Regular updates** - Keeps firmware current
|
|
||||||
4. **Checkpoint mode** - For batch processing
|
|
||||||
|
|
||||||
### For Better Battery Life
|
|
||||||
|
|
||||||
1. **Checkpoint mode** - Fewer sync requests
|
|
||||||
2. **Increase sync interval** - Fewer updates
|
|
||||||
3. **Close when not reading** - Reduces background activity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
### General Questions
|
|
||||||
|
|
||||||
**Q: Does sync work with all book formats?**
|
|
||||||
A: Yes! EPUB, PDF, MOBI, CBZ, CBR and more.
|
|
||||||
|
|
||||||
**Q: Can multiple users share a device?**
|
|
||||||
A: No, devices are tied to individual accounts for security.
|
|
||||||
|
|
||||||
**Q: What happens if I delete a book?**
|
|
||||||
A: All sync data for that book is removed from the server.
|
|
||||||
|
|
||||||
**Q: Can I export my reading data?**
|
|
||||||
A: Reading data isn't exportable from the UI yet — it's accessible via the API.
|
|
||||||
|
|
||||||
**Q: Does sync work over the internet?**
|
|
||||||
A: Yes, if your server is publicly accessible with HTTPS.
|
|
||||||
|
|
||||||
**Q: How much data does sync use?**
|
|
||||||
A: Approximately 1KB per page turn, 50KB per annotation.
|
|
||||||
|
|
||||||
### Technical Questions
|
|
||||||
|
|
||||||
**Q: How does sync handle different page counts?**
|
|
||||||
A: Uses percentage and EPUB CFI for universal positioning.
|
|
||||||
|
|
||||||
**Q: Can I sync with Calibre anymore?**
|
|
||||||
A: Bookhoard's KOReader sync uses a dedicated plugin (server-side approval, device tokens) — no Calibre involvement required.
|
|
||||||
|
|
||||||
**Q: What if I lose my device?**
|
|
||||||
A: Revoke it in settings and register a new one.
|
|
||||||
|
|
||||||
**Q: Is sync end-to-end encrypted?**
|
|
||||||
A: Yes, HTTPS/TLS 1.3 for all sync traffic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **API Docs**: `/docs/api.md`
|
|
||||||
- **Device Guides**: `/docs/devices/`
|
|
||||||
- **Self-Hosting**: `/docs/install.md`
|
|
||||||
|
|
||||||
### Support
|
|
||||||
|
|
||||||
- **Issues**: Report bugs at GitHub Issues
|
|
||||||
- **Discussions**: Join GitHub Discussions
|
|
||||||
- **Email**: support@bookhoard.example.com
|
|
||||||
|
|
||||||
### Community
|
|
||||||
|
|
||||||
- **Forum**: community.bookhoard.example.com
|
|
||||||
- **Matrix**: #bookhoard:matrix.org
|
|
||||||
- **Discord**: discord.gg/bookhoard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
### Version 1.0.x (2026)
|
|
||||||
|
|
||||||
- ✅ Web sync support
|
|
||||||
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
|
|
||||||
- ✅ Conflict resolution
|
|
||||||
- ✅ Offline queue
|
|
||||||
- ✅ Real-time WebSocket sync
|
|
||||||
- 🚧 Native Kobo sync (coming soon)
|
|
||||||
- 🚧 Mobile apps (coming later)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: August 2026
|
|
||||||
**Version**: 1.0
|
|
||||||
**License**: AGPL-3.0
|
|
||||||
+8
-2
@@ -34,7 +34,7 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
|||||||
|
|
||||||
**Light**
|
**Light**
|
||||||
|
|
||||||
- **Lupine** - Clean light with violet-blue accents (default light theme)
|
- **Lupine** - Clean light with violet-blue accents
|
||||||
- **Tokyo Night Day** - Daylight counterpart of the default
|
- **Tokyo Night Day** - Daylight counterpart of the default
|
||||||
- **Solarized Light** - Precision contrast on warm paper
|
- **Solarized Light** - Precision contrast on warm paper
|
||||||
- **One Light** - Atom's light theme
|
- **One Light** - Atom's light theme
|
||||||
@@ -52,11 +52,17 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
|||||||
|
|
||||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||||
2. Use the **Dark / Light** switch at the top to show the half you want —
|
2. Use the **Dark / Light** switch at the top to show the half you want —
|
||||||
it opens on the half your current theme lives in
|
it opens on the half your current theme lives in, and each side shows
|
||||||
|
how many themes it holds. The switch only filters the list; it never
|
||||||
|
changes your theme by itself
|
||||||
3. Pick a theme from the list — each option shows its color swatch, and your
|
3. Pick a theme from the list — each option shows its color swatch, and your
|
||||||
active theme is marked with a checkmark
|
active theme is marked with a checkmark
|
||||||
4. Your choice is saved automatically and synced across devices
|
4. Your choice is saved automatically and synced across devices
|
||||||
|
|
||||||
|
There is exactly one default: **Tokyo Night** on a fresh install. Nothing
|
||||||
|
else is pre-selected for you — pick whichever theme you like from either
|
||||||
|
half.
|
||||||
|
|
||||||
On small screens, open the sidebar with the menu button in the top bar first.
|
On small screens, open the sidebar with the menu button in the top bar first.
|
||||||
|
|
||||||
## Wood Paneling
|
## Wood Paneling
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# User Areas Guide
|
|
||||||
|
|
||||||
**Coming Soon**
|
|
||||||
|
|
||||||
This guide will cover:
|
|
||||||
|
|
||||||
- Managing your personal library
|
|
||||||
- Uploading and organizing books
|
|
||||||
- Creating and managing collections
|
|
||||||
- Syncing across devices
|
|
||||||
- Personal settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_In the meantime, check out our [Sync Guide](sync-guide.md)_
|
|
||||||
+38
-70
@@ -1,79 +1,47 @@
|
|||||||
# User Documentation
|
# Bookhoard — start here
|
||||||
|
|
||||||
Welcome to the Bookhoard user documentation. This section contains guides for using Bookhoard features, setting up devices, and configuring sync.
|
Bookhoard is a self-hosted home for the things you read and hear: your book
|
||||||
|
libraries live on your own server, you read them in the browser or on your
|
||||||
|
devices, and everything keeps track of where you are.
|
||||||
|
|
||||||
## 📱 Device Setup
|
The moving parts, in one breath:
|
||||||
|
|
||||||
Learn how to configure your e-reader devices to sync with Bookhoard:
|
- **Libraries** hold your books (and your podcasts run as feeds of their own)
|
||||||
|
- The **dashboard** is your home screen — shelves that update themselves
|
||||||
|
- **Collections** are shelves you build, by hand or by rules
|
||||||
|
- **Filters** answer "which books match?" — the same rules power saved
|
||||||
|
filters, smart collections, and dashboard shelves
|
||||||
|
- **Devices** — the Android app, KOReader on e-readers, and stock Kobo
|
||||||
|
firmware — stay in step with the server through **sync**
|
||||||
|
- Your **profile** holds your preferences: theme, playback defaults, password
|
||||||
|
|
||||||
- **[Android App Guide](devices/android-app.md)** - The native Android client
|
## Where to go
|
||||||
- Installing and upgrading the APK
|
|
||||||
- Connecting to a self-hosted server over LAN
|
|
||||||
- The Android 16+ local-network permission
|
|
||||||
- Troubleshooting login failures
|
|
||||||
|
|
||||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
| You want to... | Read |
|
||||||
- Installation on Kindle/Kobo/PocketBook
|
| --- | --- |
|
||||||
- Plugin setup with server-side device approval
|
| Learn the layout: dashboard, shelves, customizing | [Dashboard](dashboard.md) |
|
||||||
- Progress, bookmark, highlight, and note sync
|
| Browse, search, and sort your books | [Browsing your library](library-browsing.md) |
|
||||||
- OPDS catalog access
|
| Build shelves — by hand or with rules | [Collections](collections.md) |
|
||||||
- Troubleshooting
|
| Narrow with rules, save them, reuse them | [Filters — the complete guide](filters/index.md) |
|
||||||
|
| Subscribe to shows, download, listen | [Podcasts](podcasts.md) |
|
||||||
|
| Read and annotate in the browser | [Web Reader Guide](reader-guide.md) |
|
||||||
|
| Change themes and bookshelf textures | [Themes and Wood Paneling](themes.md) |
|
||||||
|
| Edit your account, password, playback defaults | [Your profile](profile-guide.md) |
|
||||||
|
| Get the Android app talking to your server | [Android App](devices/android-app.md) |
|
||||||
|
| Use KOReader on an e-reader | [KOReader Setup](devices/koreader-setup.md) |
|
||||||
|
| Use a Kobo | [Kobo Setup](devices/kobo-setup.md) |
|
||||||
|
| Understand how it all stays in sync | [Universal Sync Guide](sync-guide.md) |
|
||||||
|
| Import from Calibre | [Calibre Integration](calibre-integration.md) |
|
||||||
|
|
||||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
|
New here? The usual path is: browse [your library](library-browsing.md),
|
||||||
- In the meantime, KOReader works great on Kobo hardware
|
open a book, and let the [dashboard](dashboard.md) keep track of you. Sync
|
||||||
|
comes when you add a [device](devices/android-app.md).
|
||||||
|
|
||||||
## 🔄 Sync Configuration
|
## A note on admins
|
||||||
|
|
||||||
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features
|
Some screens (user management, server settings, library folders) are admin
|
||||||
- What is Universal Sync?
|
territory; [Admin Guide](admin-guide.md) is the door, and the admin docs are
|
||||||
- Supported devices
|
being expanded separately.
|
||||||
- Book matching and auto-linking
|
|
||||||
- Conflict resolution
|
|
||||||
- Best practices
|
|
||||||
|
|
||||||
## 📚 Calibre Integration
|
**Looking for developer documentation?** See the
|
||||||
|
[Developer Portal](../developer/development.md).
|
||||||
- **[Calibre Integration Guide](calibre-integration.md)** - Integrating with Calibre libraries
|
|
||||||
- What is Calibre?
|
|
||||||
- Automatic metadata import
|
|
||||||
- Setting up your Calibre library
|
|
||||||
- Supported metadata and file types
|
|
||||||
- Workflow examples
|
|
||||||
- Troubleshooting
|
|
||||||
- Best practices
|
|
||||||
|
|
||||||
## 📖 Reading
|
|
||||||
|
|
||||||
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
|
|
||||||
- Touch navigation (swipe to page, tap for menu)
|
|
||||||
- Selecting text: long-press, drag, handles
|
|
||||||
- Highlights, notes, copy, and editing
|
|
||||||
- PDFs and comics
|
|
||||||
|
|
||||||
## 🎨 Frontend Guide
|
|
||||||
|
|
||||||
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
## 👤 User Areas
|
|
||||||
|
|
||||||
**[User Areas Guide](user-areas.md)** - Managing your personal library and settings
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
## ⚙️ Settings
|
|
||||||
|
|
||||||
**[Settings Guide](settings-guide.md)** - Configuring your Bookhoard preferences
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
## 🔐 Admin Features
|
|
||||||
|
|
||||||
**[Admin Guide](admin-guide.md)** - Administrative functions and management
|
|
||||||
|
|
||||||
- _Coming Soon_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Looking for developer documentation?** See the [Developer Portal](../developer/development.md)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.31.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.31.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ type Collections struct {
|
|||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||||
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
||||||
@@ -355,6 +355,7 @@ type MediaItems struct {
|
|||||||
DownloadState string `db:"download_state" json:"download_state"`
|
DownloadState string `db:"download_state" json:"download_state"`
|
||||||
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
|
||||||
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
|
||||||
|
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaNotes struct {
|
type MediaNotes struct {
|
||||||
@@ -653,4 +654,5 @@ type Users struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||||
|
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.31.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -32,6 +32,11 @@ type Querier interface {
|
|||||||
BulkUpdateFormatGroups(ctx context.Context) error
|
BulkUpdateFormatGroups(ctx context.Context) error
|
||||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||||
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||||
|
// Atomic claim (§0 item 4): converts queued -> downloading in ONE
|
||||||
|
// statement. Two claimants racing on the same row: exactly one UPDATE
|
||||||
|
// matches download_state='queued'; the loser gets no rows (ErrNoRows).
|
||||||
|
// This is the SKIP-LOCKED guarantee without a second round-trip.
|
||||||
|
ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
// Cleanup expired OPDS tokens
|
// Cleanup expired OPDS tokens
|
||||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||||
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||||
@@ -257,11 +262,25 @@ type Querier interface {
|
|||||||
// rename can't spawn duplicates on the next scan.
|
// rename can't spawn duplicates on the next scan.
|
||||||
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
||||||
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
||||||
|
// The collection queue's downloaded episodes in collection order
|
||||||
|
// (plan item 2.3 "Play collection as sequence"): position-first, then
|
||||||
|
// added_at. INNER JOIN — the chain steps only through episodes the
|
||||||
|
// requester can see (RLS drops unsubscribed feeds) and that are
|
||||||
|
// downloaded (invariant 6: un-downloaded episodes are never tracked).
|
||||||
|
GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error)
|
||||||
// Get collection items
|
// Get collection items
|
||||||
// A2c: position-first ordering (playlist reading order); un-positioned
|
// A2c: position-first ordering (playlist reading order); un-positioned
|
||||||
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
||||||
// which walked a playlist backwards.
|
// which walked a playlist backwards.
|
||||||
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
||||||
|
// Episode-collections detail (plan item 2.3): the collection's full
|
||||||
|
// membership for the SSR detail page, with per-row visibility. LEFT
|
||||||
|
// JOIN + RLS: rows whose media is invisible to the requester
|
||||||
|
// (unsubscribed episode — or a family-lens hidden book) survive with
|
||||||
|
// NULL media columns and visible=false, so the page can render an
|
||||||
|
// "unavailable" hint instead of silently vanishing them. Rows visible
|
||||||
|
// but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
|
||||||
|
GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error)
|
||||||
// The excluded filter lives here (was: callers filtered ci.excluded in
|
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||||
// returns the real row so neither exists).
|
// returns the real row so neither exists).
|
||||||
@@ -446,8 +465,6 @@ type Querier interface {
|
|||||||
// GetMediaScanState reads one item's manifest (per-item rescan paths
|
// GetMediaScanState reads one item's manifest (per-item rescan paths
|
||||||
// and the manifest integration tests).
|
// and the manifest integration tests).
|
||||||
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
|
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
|
||||||
// Sequential download worker's claim source (single worker, day one).
|
|
||||||
GetNextQueuedEpisode(ctx context.Context) (MediaItems, error)
|
|
||||||
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
||||||
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
||||||
// Get OPDS token
|
// Get OPDS token
|
||||||
@@ -582,12 +599,6 @@ type Querier interface {
|
|||||||
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
|
||||||
// membership comes from the candidate book set instead of collection_items.
|
|
||||||
// The set is scoped to the requesting user's visible libraries (family
|
|
||||||
// lens enforced at the source) and live items only — the same filters the
|
|
||||||
// static collection paths apply.
|
|
||||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
|
||||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||||
// device's user_id before calling this).
|
// device's user_id before calling this).
|
||||||
@@ -599,6 +610,12 @@ type Querier interface {
|
|||||||
// Link unlinked book to media item
|
// Link unlinked book to media item
|
||||||
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
||||||
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||||
|
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||||
|
// EVERY user with its maintenance aggregates. Runs under the first-
|
||||||
|
// admin identity (invariant 12 — same contract as the poller). Sorted
|
||||||
|
// by disk usage DESC: the maintenance question is "what's eating the
|
||||||
|
// disk", so the hogs surface first.
|
||||||
|
ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error)
|
||||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
||||||
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -656,6 +673,11 @@ type Querier interface {
|
|||||||
// Item-side projections COALESCE to keep them non-nullable strings;
|
// Item-side projections COALESCE to keep them non-nullable strings;
|
||||||
// the display file path falls back to the issue's own for row-less rows.
|
// the display file path falls back to the issue's own for row-less rows.
|
||||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||||
|
// Fair-share candidate scan (§0 item 4): the download pool claims in
|
||||||
|
// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
|
||||||
|
// enqueue loop's ordering IS the download order, item 5). The worker
|
||||||
|
// groups these per feed and prefers the least-recently-served feed.
|
||||||
|
ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error)
|
||||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||||
// Download All + policy union worklist.
|
// Download All + policy union worklist.
|
||||||
@@ -668,6 +690,9 @@ type Querier interface {
|
|||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
// First consecutive scan that cannot find the file on disk.
|
// First consecutive scan that cannot find the file on disk.
|
||||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
||||||
|
// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
||||||
|
// concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
||||||
// SUCCESS-only write (invariant 7): the file is on disk and probed.
|
// SUCCESS-only write (invariant 7): the file is on disk and probed.
|
||||||
// ffprobe facts (real duration via audio_metadata, precise size/mime)
|
// ffprobe facts (real duration via audio_metadata, precise size/mime)
|
||||||
// reconcile the feed's declared values.
|
// reconcile the feed's declared values.
|
||||||
@@ -710,6 +735,17 @@ type Querier interface {
|
|||||||
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
||||||
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||||
|
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
||||||
|
// was removed — reset the row to pending and clear the download-derived
|
||||||
|
// facts. file_path is deliberately KEPT: it is the fetch-maintained
|
||||||
|
// layout (written at fetch time, not download time), so a re-download
|
||||||
|
// lands on the same path. The poll upsert never touches download_state,
|
||||||
|
// so a deleted file stays deleted (invariant 2: polling never deletes;
|
||||||
|
// admins delete knowingly).
|
||||||
|
ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// Same reset for EVERY downloaded episode of a feed (per-feed
|
||||||
|
// "Delete files" action on the admin view).
|
||||||
|
ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||||
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||||
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||||
@@ -734,6 +770,14 @@ type Querier interface {
|
|||||||
RevokeOpdsToken(ctx context.Context, token string) error
|
RevokeOpdsToken(ctx context.Context, token string) error
|
||||||
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
|
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
|
||||||
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error)
|
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error)
|
||||||
|
// Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
|
||||||
|
// The podcasts exclusion in SearchMediaItemsUnified stays (books
|
||||||
|
// surfaces never list podcasts — plan §9); THIS query is the episode
|
||||||
|
// picker's dedicated scope. Visibility is the engine's job (D20): the
|
||||||
|
// media_items policy drops episodes from unsubscribed feeds and the
|
||||||
|
// podcast_feeds join is governed the same way — non-subscribers see
|
||||||
|
// nothing here without any Go-side clause.
|
||||||
|
SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error)
|
||||||
SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
|
SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
|
||||||
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
|
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
|
||||||
// Search Media Items queries
|
// Search Media Items queries
|
||||||
@@ -753,10 +797,11 @@ type Querier interface {
|
|||||||
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
|
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
|
||||||
// Library Visibility queries
|
// Library Visibility queries
|
||||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||||
// Worker claim: pending|failed -> queued. Attempts incremented at
|
// Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
|
||||||
// claim time so a crashed worker still counts the try.
|
// (§0 items 4/5) so claim order = enqueue order (the effective
|
||||||
|
// listening order the enqueue loop applied). Attempts increment at
|
||||||
|
// enqueue so a crashed worker still counts the try.
|
||||||
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
|
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
|
||||||
SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) error
|
|
||||||
// Manual retry (same endpoint as download): reset to pending so the
|
// Manual retry (same endpoint as download): reset to pending so the
|
||||||
// worker re-claims it; the reason text clears with the fresh attempt.
|
// worker re-claims it; the reason text clears with the fresh attempt.
|
||||||
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error
|
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error
|
||||||
@@ -775,11 +820,12 @@ type Querier interface {
|
|||||||
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
|
||||||
// of the JSONB without touching metadata stale clients did not send).
|
|
||||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
|
||||||
// A2c §2.4: provenance + playlist flags on an existing collection without
|
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
// touching the rest of the row.
|
// touching the rest of the row.
|
||||||
|
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
||||||
|
// collections has no updated_at column — the scanner's series-import
|
||||||
|
// call has been failing silently, its error discarded. Fixed by
|
||||||
|
// dropping the phantom column write.)
|
||||||
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||||
@@ -848,6 +894,7 @@ type Querier interface {
|
|||||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||||
// Update universal progress
|
// Update universal progress
|
||||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||||
|
UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error
|
||||||
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||||
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
|
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
|
||||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||||
|
|||||||
+608
-361
File diff suppressed because it is too large.
Load diff
@@ -30,6 +30,7 @@ SELECT
|
|||||||
u.timezone,
|
u.timezone,
|
||||||
u.default_audio_speed,
|
u.default_audio_speed,
|
||||||
u.keep_screen_awake,
|
u.keep_screen_awake,
|
||||||
|
u.audio_resume_rewind_seconds,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1;
|
WHERE u.id = $1;
|
||||||
@@ -526,6 +527,9 @@ UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
|
|||||||
-- name: UpdateUserKeepScreenAwake :exec
|
-- name: UpdateUserKeepScreenAwake :exec
|
||||||
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
|
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateUserAudioResumeRewind :exec
|
||||||
|
UPDATE users SET audio_resume_rewind_seconds = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
-- Per-book playback speed preference (player plan §8).
|
-- Per-book playback speed preference (player plan §8).
|
||||||
|
|
||||||
-- name: GetAudioPlaybackSpeed :one
|
-- name: GetAudioPlaybackSpeed :one
|
||||||
@@ -932,6 +936,26 @@ ORDER BY
|
|||||||
mi.title ASC
|
mi.title ASC
|
||||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||||
|
|
||||||
|
-- name: SearchEpisodesForPicker :many
|
||||||
|
-- Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
|
||||||
|
-- The podcasts exclusion in SearchMediaItemsUnified stays (books
|
||||||
|
-- surfaces never list podcasts — plan §9); THIS query is the episode
|
||||||
|
-- picker's dedicated scope. Visibility is the engine's job (D20): the
|
||||||
|
-- media_items policy drops episodes from unsubscribed feeds and the
|
||||||
|
-- podcast_feeds join is governed the same way — non-subscribers see
|
||||||
|
-- nothing here without any Go-side clause.
|
||||||
|
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
|
||||||
|
mi.format_group, mi.download_state,
|
||||||
|
pf.title AS feed_title
|
||||||
|
FROM media_items mi
|
||||||
|
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
||||||
|
WHERE mi.source = 'podcast_episode'
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
AND (sqlc.arg('q')::text = '' OR mi.title ILIKE '%' || sqlc.arg('q')::text || '%')
|
||||||
|
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
|
||||||
|
LIMIT 60;
|
||||||
|
|
||||||
-- name: SearchAuthorValues :many
|
-- name: SearchAuthorValues :many
|
||||||
SELECT
|
SELECT
|
||||||
mi.author as value,
|
mi.author as value,
|
||||||
@@ -3469,14 +3493,18 @@ DELETE FROM device_file_aliases WHERE id = $1;
|
|||||||
|
|
||||||
-- Create collection
|
-- Create collection
|
||||||
-- name: CreateCollection :one
|
-- name: CreateCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
INSERT INTO collections (user_id, name, description, color, icon, view_settings, show_on_dashboard, filter_tree)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
-- touching the rest of the row.
|
-- touching the rest of the row.
|
||||||
-- name: UpdateCollectionViewSettings :exec
|
-- name: UpdateCollectionViewSettings :exec
|
||||||
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
|
-- (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
||||||
|
-- collections has no updated_at column — the scanner's series-import
|
||||||
|
-- call has been failing silently, its error discarded. Fixed by
|
||||||
|
-- dropping the phantom column write.)
|
||||||
|
UPDATE collections SET view_settings = $2 WHERE id = $1;
|
||||||
|
|
||||||
-- Get collection
|
-- Get collection
|
||||||
-- name: GetCollection :one
|
-- name: GetCollection :one
|
||||||
@@ -3504,16 +3532,11 @@ SET
|
|||||||
description = $3,
|
description = $3,
|
||||||
color = $4,
|
color = $4,
|
||||||
icon = $5,
|
icon = $5,
|
||||||
auto_assign_rules = $6,
|
view_settings = $6,
|
||||||
view_settings = $7
|
filter_tree = $7
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
|
||||||
-- of the JSONB without touching metadata stale clients did not send).
|
|
||||||
-- name: UpdateCollectionRulesJSON :one
|
|
||||||
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *;
|
|
||||||
|
|
||||||
-- Delete collection
|
-- Delete collection
|
||||||
-- name: DeleteCollection :exec
|
-- name: DeleteCollection :exec
|
||||||
DELETE FROM collections WHERE id = $1;
|
DELETE FROM collections WHERE id = $1;
|
||||||
@@ -3576,6 +3599,42 @@ WHERE ci.collection_id = $1
|
|||||||
AND mi.missing_scan_count = 0
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||||
|
|
||||||
|
-- name: GetCollectionItemsDetailed :many
|
||||||
|
-- Episode-collections detail (plan item 2.3): the collection's full
|
||||||
|
-- membership for the SSR detail page, with per-row visibility. LEFT
|
||||||
|
-- JOIN + RLS: rows whose media is invisible to the requester
|
||||||
|
-- (unsubscribed episode — or a family-lens hidden book) survive with
|
||||||
|
-- NULL media columns and visible=false, so the page can render an
|
||||||
|
-- "unavailable" hint instead of silently vanishing them. Rows visible
|
||||||
|
-- but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
|
||||||
|
SELECT ci.media_item_id, ci.position, ci.added_at,
|
||||||
|
(mi.id IS NOT NULL)::bool AS visible,
|
||||||
|
mi.title, mi.author, mi.cover_image_path, mi.format_group,
|
||||||
|
mi.source, mi.download_state,
|
||||||
|
pf.title AS feed_title
|
||||||
|
FROM collection_items ci
|
||||||
|
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
|
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
|
||||||
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||||
|
|
||||||
|
-- name: GetCollectionEpisodesOrdered :many
|
||||||
|
-- The collection queue's downloaded episodes in collection order
|
||||||
|
-- (plan item 2.3 "Play collection as sequence"): position-first, then
|
||||||
|
-- added_at. INNER JOIN — the chain steps only through episodes the
|
||||||
|
-- requester can see (RLS drops unsubscribed feeds) and that are
|
||||||
|
-- downloaded (invariant 6: un-downloaded episodes are never tracked).
|
||||||
|
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
|
||||||
|
FROM collection_items ci
|
||||||
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
AND mi.source = 'podcast_episode'
|
||||||
|
AND mi.download_state = 'downloaded'
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||||
|
|
||||||
-- Get collections for book
|
-- Get collections for book
|
||||||
-- name: GetCollectionsForBook :many
|
-- name: GetCollectionsForBook :many
|
||||||
SELECT c.*
|
SELECT c.*
|
||||||
@@ -3967,8 +4026,8 @@ RETURNING *;
|
|||||||
DELETE FROM collection_items WHERE collection_id = $1;
|
DELETE FROM collection_items WHERE collection_id = $1;
|
||||||
|
|
||||||
-- name: CreateSystemCollection :one
|
-- name: CreateSystemCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ResetSystemCollectionMetadata :exec
|
-- name: ResetSystemCollectionMetadata :exec
|
||||||
@@ -4084,19 +4143,6 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- name: CountCollectionItems :one
|
-- name: CountCollectionItems :one
|
||||||
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
||||||
|
|
||||||
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
|
||||||
-- membership comes from the candidate book set instead of collection_items.
|
|
||||||
-- The set is scoped to the requesting user's visible libraries (family
|
|
||||||
-- lens enforced at the source) and live items only — the same filters the
|
|
||||||
-- static collection paths apply.
|
|
||||||
-- name: GetVisibleLiveMediaForRules :many
|
|
||||||
SELECT mi.* FROM media_items mi
|
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
||||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
||||||
AND mi.archived_at IS NULL
|
|
||||||
AND mi.missing_scan_count = 0
|
|
||||||
ORDER BY mi.title ASC;
|
|
||||||
|
|
||||||
-- Live item count for a STATIC collection (rule-free cards on the
|
-- Live item count for a STATIC collection (rule-free cards on the
|
||||||
-- collections page); same live filter as GetCollectionItems, plus the
|
-- collections page); same live filter as GetCollectionItems, plus the
|
||||||
-- future-hybrid excluded exclusion and an optional library scope.
|
-- future-hybrid excluded exclusion and an optional library scope.
|
||||||
@@ -4698,6 +4744,68 @@ WHERE feed_id = $1;
|
|||||||
-- name: CountPodcastFeedSubscribers :one
|
-- name: CountPodcastFeedSubscribers :one
|
||||||
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
|
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
|
||||||
|
|
||||||
|
-- name: ListAllPodcastFeedsAdmin :many
|
||||||
|
-- Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||||
|
-- EVERY user with its maintenance aggregates. Runs under the first-
|
||||||
|
-- admin identity (invariant 12 — same contract as the poller). Sorted
|
||||||
|
-- by disk usage DESC: the maintenance question is "what's eating the
|
||||||
|
-- disk", so the hogs surface first.
|
||||||
|
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
|
||||||
|
l.name AS library_name,
|
||||||
|
(SELECT COUNT(*) FROM media_items m
|
||||||
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
|
||||||
|
(SELECT COUNT(*) FROM media_items m
|
||||||
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||||
|
AND m.download_state = 'downloaded') AS episode_downloaded,
|
||||||
|
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
|
||||||
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||||
|
AND m.download_state = 'downloaded') AS disk_bytes,
|
||||||
|
(SELECT COUNT(*) FROM podcast_subscriptions s
|
||||||
|
WHERE s.feed_id = f.id) AS subscriber_count,
|
||||||
|
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
|
||||||
|
FROM podcast_subscriptions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.feed_id = f.id) AS subscriber_names
|
||||||
|
FROM podcast_feeds f
|
||||||
|
JOIN libraries l ON l.id = f.library_id
|
||||||
|
ORDER BY 9 DESC, f.title ASC;
|
||||||
|
|
||||||
|
-- name: ResetPodcastEpisodeFileDeleted :exec
|
||||||
|
-- Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
||||||
|
-- was removed — reset the row to pending and clear the download-derived
|
||||||
|
-- facts. file_path is deliberately KEPT: it is the fetch-maintained
|
||||||
|
-- layout (written at fetch time, not download time), so a re-download
|
||||||
|
-- lands on the same path. The poll upsert never touches download_state,
|
||||||
|
-- so a deleted file stays deleted (invariant 2: polling never deletes;
|
||||||
|
-- admins delete knowingly).
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'pending',
|
||||||
|
download_error = NULL,
|
||||||
|
download_attempts = 0,
|
||||||
|
file_size = NULL,
|
||||||
|
mime_type = NULL,
|
||||||
|
cover_image_path = NULL,
|
||||||
|
audio_metadata = NULL,
|
||||||
|
chapter_count = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded';
|
||||||
|
|
||||||
|
-- name: ResetPodcastFeedFilesDeleted :execrows
|
||||||
|
-- Same reset for EVERY downloaded episode of a feed (per-feed
|
||||||
|
-- "Delete files" action on the admin view).
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'pending',
|
||||||
|
download_error = NULL,
|
||||||
|
download_attempts = 0,
|
||||||
|
file_size = NULL,
|
||||||
|
mime_type = NULL,
|
||||||
|
cover_image_path = NULL,
|
||||||
|
audio_metadata = NULL,
|
||||||
|
chapter_count = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
||||||
|
AND download_state = 'downloaded';
|
||||||
|
|
||||||
-- name: ListPodcastFeedsForPolling :many
|
-- name: ListPodcastFeedsForPolling :many
|
||||||
-- Poller worklist (admin identity): every feed with its validators and
|
-- Poller worklist (admin identity): every feed with its validators and
|
||||||
-- the back-off flag (is_complete feeds poll rarely, never disappear).
|
-- the back-off flag (is_complete feeds poll rarely, never disappear).
|
||||||
@@ -4862,10 +4970,13 @@ SELECT * FROM media_items
|
|||||||
WHERE id = $1 AND source = 'podcast_episode';
|
WHERE id = $1 AND source = 'podcast_episode';
|
||||||
|
|
||||||
-- name: SetPodcastEpisodeDownloadQueued :execrows
|
-- name: SetPodcastEpisodeDownloadQueued :execrows
|
||||||
-- Worker claim: pending|failed -> queued. Attempts incremented at
|
-- Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
|
||||||
-- claim time so a crashed worker still counts the try.
|
-- (§0 items 4/5) so claim order = enqueue order (the effective
|
||||||
|
-- listening order the enqueue loop applied). Attempts increment at
|
||||||
|
-- enqueue so a crashed worker still counts the try.
|
||||||
UPDATE media_items
|
UPDATE media_items
|
||||||
SET download_state = 'queued',
|
SET download_state = 'queued',
|
||||||
|
download_queue_pos = nextval('podcast_queue_seq'),
|
||||||
download_error = NULL,
|
download_error = NULL,
|
||||||
download_attempts = download_attempts + 1,
|
download_attempts = download_attempts + 1,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
@@ -4873,10 +4984,9 @@ WHERE id = $1
|
|||||||
AND source = 'podcast_episode'
|
AND source = 'podcast_episode'
|
||||||
AND download_state IN ('pending', 'failed');
|
AND download_state IN ('pending', 'failed');
|
||||||
|
|
||||||
-- name: SetPodcastEpisodeDownloading :exec
|
-- SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
||||||
UPDATE media_items
|
-- GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
||||||
SET download_state = 'downloading', updated_at = NOW()
|
-- concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
||||||
WHERE id = $1 AND source = 'podcast_episode';
|
|
||||||
|
|
||||||
-- name: MarkPodcastEpisodeDownloaded :one
|
-- name: MarkPodcastEpisodeDownloaded :one
|
||||||
-- SUCCESS-only write (invariant 7): the file is on disk and probed.
|
-- SUCCESS-only write (invariant 7): the file is on disk and probed.
|
||||||
@@ -4964,12 +5074,26 @@ WHERE s.user_id = sqlc.arg('user_id')
|
|||||||
OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%')
|
OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%')
|
||||||
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
||||||
|
|
||||||
-- name: GetNextQueuedEpisode :one
|
-- name: ListQueuedPodcastEpisodes :many
|
||||||
-- Sequential download worker's claim source (single worker, day one).
|
-- Fair-share candidate scan (§0 item 4): the download pool claims in
|
||||||
SELECT * FROM media_items
|
-- TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
|
||||||
|
-- enqueue loop's ordering IS the download order, item 5). The worker
|
||||||
|
-- groups these per feed and prefers the least-recently-served feed.
|
||||||
|
SELECT id, source_feed_id FROM media_items
|
||||||
WHERE source = 'podcast_episode' AND download_state = 'queued'
|
WHERE source = 'podcast_episode' AND download_state = 'queued'
|
||||||
ORDER BY updated_at ASC
|
ORDER BY download_queue_pos ASC NULLS LAST;
|
||||||
LIMIT 1;
|
|
||||||
|
-- name: ClaimQueuedPodcastEpisode :one
|
||||||
|
-- Atomic claim (§0 item 4): converts queued -> downloading in ONE
|
||||||
|
-- statement. Two claimants racing on the same row: exactly one UPDATE
|
||||||
|
-- matches download_state='queued'; the loser gets no rows (ErrNoRows).
|
||||||
|
-- This is the SKIP-LOCKED guarantee without a second round-trip.
|
||||||
|
UPDATE media_items
|
||||||
|
SET download_state = 'downloading', updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND source = 'podcast_episode'
|
||||||
|
AND download_state = 'queued'
|
||||||
|
RETURNING media_items.*;
|
||||||
|
|
||||||
-- name: CountQueuedEpisodes :one
|
-- name: CountQueuedEpisodes :one
|
||||||
SELECT COUNT(*) FROM media_items
|
SELECT COUNT(*) FROM media_items
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
// SettingType enumerates the value types stored in system_settings.setting_type.
|
// SettingType enumerates the value types stored in system_settings.setting_type.
|
||||||
const (
|
const (
|
||||||
SettingTypeInt = "int"
|
SettingTypeInt = "int"
|
||||||
|
SettingTypeFloat = "float"
|
||||||
SettingTypeBool = "bool"
|
SettingTypeBool = "bool"
|
||||||
SettingTypeString = "string"
|
SettingTypeString = "string"
|
||||||
SettingTypeStringList = "string_list"
|
SettingTypeStringList = "string_list"
|
||||||
@@ -83,6 +84,8 @@ var SettingDefaults = []SettingDefault{
|
|||||||
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
||||||
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
||||||
{Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"},
|
{Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"},
|
||||||
|
{Key: "podcast_download_concurrency", Value: "4", Type: SettingTypeInt, Min: "1", Max: "16", Category: "podcasts", Group: "Podcasts", Description: "Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)"},
|
||||||
|
{Key: "podcast_download_speed_mbps", Value: "0", Type: SettingTypeFloat, Min: "0", Max: "10000", Category: "podcasts", Group: "Podcasts", Description: "Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
||||||
@@ -173,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *SettingsRegistry) getFloat(key string) float64 {
|
||||||
|
d := defaultBy[key]
|
||||||
|
v := r.raw(key)
|
||||||
|
f, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
f, _ = strconv.ParseFloat(d.Value, 64)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
|
||||||
|
f = mn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
|
||||||
|
f = mx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (r *SettingsRegistry) getBool(key string) bool {
|
func (r *SettingsRegistry) getBool(key string) bool {
|
||||||
v := r.raw(key)
|
v := r.raw(key)
|
||||||
b, err := strconv.ParseBool(v)
|
b, err := strconv.ParseBool(v)
|
||||||
@@ -189,13 +212,24 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
|||||||
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
// PodcastPollInterval is the feed-poller tick (live setting; the poller
|
// PodcastPollInterval is how often the poller checks feeds, as a
|
||||||
// re-reads it every cycle — writes go through the settings API path,
|
// duration (live setting; the poller re-reads it every cycle — writes
|
||||||
// AGENTS.md gotcha #2).
|
// go through the settings API path, AGENTS.md gotcha #2).
|
||||||
func (r *SettingsRegistry) PodcastPollInterval() time.Duration {
|
func (r *SettingsRegistry) PodcastPollInterval() time.Duration {
|
||||||
return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second
|
return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PodcastDownloadConcurrency: episode downloads in flight server-wide
|
||||||
|
// (the connection is the limit; fair-share spreads them across feeds).
|
||||||
|
func (r *SettingsRegistry) PodcastDownloadConcurrency() int {
|
||||||
|
return r.getInt("podcast_download_concurrency")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastDownloadSpeedMbps: shared bandwidth cap (0 = unlimited).
|
||||||
|
func (r *SettingsRegistry) PodcastDownloadSpeedMbps() float64 {
|
||||||
|
return r.getFloat("podcast_download_speed_mbps")
|
||||||
|
}
|
||||||
|
|
||||||
// AutoScanEnabled reports whether auto-scanning is on.
|
// AutoScanEnabled reports whether auto-scanning is on.
|
||||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ func TestSettingDefaults(t *testing.T) {
|
|||||||
if _, err := strconv.ParseBool(d.Value); err != nil {
|
if _, err := strconv.ParseBool(d.Value); err != nil {
|
||||||
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
|
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
|
||||||
}
|
}
|
||||||
|
case SettingTypeFloat:
|
||||||
|
f, err := strconv.ParseFloat(d.Value, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("float setting %s default %q is not a float", d.Key, d.Value)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
|
||||||
|
t.Errorf("float setting %s default %q below min %q", d.Key, d.Value, d.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
|
||||||
|
t.Errorf("float setting %s default %q above max %q", d.Key, d.Value, d.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
case SettingTypeString:
|
case SettingTypeString:
|
||||||
if d.Value == "" {
|
if d.Value == "" {
|
||||||
t.Errorf("string setting %s has empty default", d.Key)
|
t.Errorf("string setting %s has empty default", d.Key)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
@@ -24,7 +25,23 @@ type DocsHandler struct {
|
|||||||
docsFS fs.FS
|
docsFS fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveDocsPath — the docs folder lives at the repo root, but `go test`
|
||||||
|
// runs test binaries from the PACKAGE directory (/cmd/server/tests), so a
|
||||||
|
// plain relative "docs" resolves to a folder that doesn't exist there and
|
||||||
|
// every /docs route 404s in tests (2026-11-01). Walk upward, bounded,
|
||||||
|
// until the folder is real; prod (/app) matches on the first try.
|
||||||
|
func resolveDocsPath(p string) string {
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
if fi, err := os.Stat(filepath.Join(p, "index.md")); err == nil && !fi.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
p = filepath.Join("..", p)
|
||||||
|
}
|
||||||
|
return "docs"
|
||||||
|
}
|
||||||
|
|
||||||
func NewDocsHandler(docsPath string) *DocsHandler {
|
func NewDocsHandler(docsPath string) *DocsHandler {
|
||||||
|
docsPath = resolveDocsPath(docsPath)
|
||||||
// Create goldmark renderer with syntax highlighting
|
// Create goldmark renderer with syntax highlighting
|
||||||
md := goldmark.New(
|
md := goldmark.New(
|
||||||
goldmark.WithExtensions(
|
goldmark.WithExtensions(
|
||||||
@@ -74,11 +91,24 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the markdown file
|
// Read the markdown file. Folder URLs resolve to their index page:
|
||||||
|
// /docs/user/filters → user/filters/index.md (2026-11-01 — the
|
||||||
|
// filter manual is a folder: guide + cookbook + generated field
|
||||||
|
// reference).
|
||||||
content, err := fs.ReadFile(h.docsFS, docPath)
|
content, err := fs.ReadFile(h.docsFS, docPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if !strings.HasSuffix(docPath, "index.md") {
|
||||||
|
alt := strings.TrimSuffix(docPath, ".md") + "/index.md"
|
||||||
|
if altContent, altErr := fs.ReadFile(h.docsFS, alt); altErr == nil {
|
||||||
|
docPath = alt
|
||||||
|
content = altContent
|
||||||
|
} else {
|
||||||
return nil, fmt.Errorf("failed to read document: %w", err)
|
return nil, fmt.Errorf("failed to read document: %w", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("failed to read document: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert markdown to HTML
|
// Convert markdown to HTML
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
+29
-14
@@ -8,40 +8,55 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDocsRelativeLinks keeps the developer docs tree internally navigable:
|
// TestDocsRelativeLinks keeps every doc tree internally navigable: every
|
||||||
// every relative markdown link must point at a file that exists. The
|
// relative markdown link must point at a file that exists. The api-reference
|
||||||
// api-reference overview once shipped 20+ directory-style links
|
// overview once shipped 20+ directory-style links (authentication/ etc.)
|
||||||
// (authentication/ etc.) that 404'd — this gate makes that class of rot
|
// that 404'd — this gate makes that class of rot fail make test.
|
||||||
// fail make test. (docs/user is excluded: end-user prose has its own pass.)
|
|
||||||
//
|
//
|
||||||
// Rules:
|
// Rules:
|
||||||
// - http(s):, mailto:, and pure #anchors are skipped
|
// - http(s):, mailto:, and pure #anchors are skipped
|
||||||
// - every other target must end in .md and resolve (relative to the
|
// - every other target must end in .md and resolve (relative to the
|
||||||
// linking file) to an existing file inside the tree
|
// linking file) to an existing file inside the docs tree
|
||||||
// - directory-style targets ("area/") are the exact bug this test exists
|
// - directory-style targets ("area/") are the exact bug this test exists
|
||||||
// for and always fail
|
// for and always fail
|
||||||
|
//
|
||||||
|
// Hands-off sources (project owner instruction, 2026-11-02: DO NOT EDIT THE
|
||||||
|
// FILTER DOCS): docs/user/filters/** and docs/user/searching-and-filtering.md
|
||||||
|
// are not scanned as link sources, so no future gate failure can force an
|
||||||
|
// edit there. Files linking INTO them are still checked — resolution needs
|
||||||
|
// file existence only, never edits to their content.
|
||||||
|
|
||||||
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
|
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
|
||||||
|
|
||||||
func TestDocsRelativeLinks(t *testing.T) {
|
var handsOffFilterDocs = []string{
|
||||||
// Walk developer docs; escape-check against the whole docs tree —
|
"user/filters/",
|
||||||
// cross-area links into docs/user are legitimate (same site).
|
"user/searching-and-filtering.md",
|
||||||
devRoot, err := filepath.Abs("../../docs/developer")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("abs developer root: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDocsRelativeLinks(t *testing.T) {
|
||||||
treeRoot, err := filepath.Abs("../../docs")
|
treeRoot, err := filepath.Abs("../../docs")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("abs docs root: %v", err)
|
t.Fatalf("abs docs root: %v", err)
|
||||||
}
|
}
|
||||||
err = filepath.WalkDir(devRoot, func(path string, d os.DirEntry, err error) error {
|
err = filepath.WalkDir(treeRoot, func(path string, d os.DirEntry, err error) error {
|
||||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
rel, err := filepath.Rel(treeRoot, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
for _, off := range handsOffFilterDocs {
|
||||||
|
if strings.HasPrefix(relSlash, off) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rel, _ := filepath.Rel(docsRoot, path)
|
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -321,10 +321,23 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
|
|||||||
return nav
|
return nav
|
||||||
}
|
}
|
||||||
|
|
||||||
// containsURL reports whether currentPath is one of the given doc URLs.
|
// normalizeDocURL — folder-index pages SERVE at their directory URL
|
||||||
|
// (/docs/user/filters) while their nav entry carries the /index suffix
|
||||||
|
// (/docs/user/filters/index): same page, two spellings. Both sidebar
|
||||||
|
// checks (expansion + highlight) compare normalized forms, so folder
|
||||||
|
// pages expand and highlight like file pages — no per-page special
|
||||||
|
// cases (2026-11-01, the filter manual's folder).
|
||||||
|
func normalizeDocURL(u string) string {
|
||||||
|
u = strings.TrimRight(u, "/")
|
||||||
|
return strings.TrimSuffix(u, "/index")
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsURL reports whether currentPath is one of the given doc URLs
|
||||||
|
// (folder-index URLs normalized — see normalizeDocURL).
|
||||||
func containsURL(currentPath string, urls []string) bool {
|
func containsURL(currentPath string, urls []string) bool {
|
||||||
|
current := normalizeDocURL(currentPath)
|
||||||
for _, u := range urls {
|
for _, u := range urls {
|
||||||
if currentPath == u {
|
if current == normalizeDocURL(u) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"bookhoard/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlaceDoc(t *testing.T) {
|
func TestPlaceDoc(t *testing.T) {
|
||||||
@@ -205,3 +207,41 @@ func TestBuildNavigationNothingCurrent(t *testing.T) {
|
|||||||
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
|
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildNavigationFolderIndex (2026-11-01): folder-index pages serve
|
||||||
|
// at their directory URL (/docs/user/filters) while their nav entries
|
||||||
|
// carry /index (/docs/user/filters/index) — the sidebar must EXPAND the
|
||||||
|
// containing section/group and HIGHLIGHT the index item for the folder
|
||||||
|
// URL, exactly like a file page. The filter manual's folder is the
|
||||||
|
// first page of this shape.
|
||||||
|
func TestBuildNavigationFolderIndex(t *testing.T) {
|
||||||
|
// The REAL docs tree: the Filters group only exists because the
|
||||||
|
// filter manual (guide/cookbook/fields) lives in user/filters/.
|
||||||
|
handler := NewDocsHandler("../../docs")
|
||||||
|
nav := handler.BuildNavigation("/docs/user/filters")
|
||||||
|
|
||||||
|
var userGuide *templates.NavSection
|
||||||
|
for i := range nav.Sections {
|
||||||
|
if nav.Sections[i].Title == "User Guide" {
|
||||||
|
userGuide = &nav.Sections[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userGuide == nil {
|
||||||
|
t.Fatal("User Guide section missing")
|
||||||
|
}
|
||||||
|
if userGuide.Collapsed {
|
||||||
|
t.Error("User Guide section must expand for /docs/user/filters")
|
||||||
|
}
|
||||||
|
var filters *templates.NavGroup
|
||||||
|
for i := range userGuide.Groups {
|
||||||
|
if userGuide.Groups[i].Title == "Filters" {
|
||||||
|
filters = &userGuide.Groups[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filters == nil {
|
||||||
|
t.Fatal("Filters group missing from User Guide")
|
||||||
|
}
|
||||||
|
if filters.Collapsed {
|
||||||
|
t.Error("Filters group must expand for its folder URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-25
@@ -49,7 +49,7 @@ var Schemas = map[string]Schema{
|
|||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "collection_type": "",
|
"color": "", "icon": "", "collection_type": "",
|
||||||
"auto_assign_rules": null, "view_settings": {},
|
"view_settings": {},
|
||||||
"book_count": 0, "created_at": ""
|
"book_count": 0, "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@ var Schemas = map[string]Schema{
|
|||||||
"GET /api/collections/:id": {
|
"GET /api/collections/:id": {
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "auto_assign_rules": null,
|
"color": "", "icon": "",
|
||||||
"view_settings": {}, "books": [], "book_count": 0,
|
"view_settings": {}, "books": [], "book_count": 0,
|
||||||
"is_smart": false, "up_next": [], "created_at": ""
|
"is_smart": false, "up_next": [], "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
@@ -71,7 +71,7 @@ var Schemas = map[string]Schema{
|
|||||||
Request: handlers.UpdateCollectionRequest{},
|
Request: handlers.UpdateCollectionRequest{},
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "auto_assign_rules": null,
|
"color": "", "icon": "",
|
||||||
"view_settings": {}, "created_at": ""
|
"view_settings": {}, "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
@@ -601,15 +601,14 @@ var Schemas = map[string]Schema{
|
|||||||
Request: json.RawMessage(`{"item_ids": []}`),
|
Request: json.RawMessage(`{"item_ids": []}`),
|
||||||
Response: json.RawMessage(`{"status": "reordered"}`),
|
Response: json.RawMessage(`{"status": "reordered"}`),
|
||||||
},
|
},
|
||||||
"PUT /api/collections/:id/rules/:ruleId": {
|
"PUT /api/collections/:id/view-settings": {
|
||||||
// Handler is SetRuleEnabled: toggle only, request {enabled required},
|
// Merges chain_order into the collection's view_settings JSONB
|
||||||
// response {collection_id, is_smart}.
|
// (playlist toggles share the doc and are preserved).
|
||||||
Request: json.RawMessage(`{"enabled": false}`),
|
Request: json.RawMessage(`{"chain_order": "newest"}`),
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"collection_id": "", "is_smart": false
|
"id": "", "chain_order": "", "view_settings": {}
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Beam batch 7: scanner ---
|
// --- Beam batch 7: scanner ---
|
||||||
"POST /api/scanner/scan": {
|
"POST /api/scanner/scan": {
|
||||||
Request: handlers.ScanLibraryRequest{},
|
Request: handlers.ScanLibraryRequest{},
|
||||||
@@ -835,22 +834,6 @@ var Schemas = map[string]Schema{
|
|||||||
"DELETE /api/devices/:id/file-aliases/:aliasId": {
|
"DELETE /api/devices/:id/file-aliases/:aliasId": {
|
||||||
Response: json.RawMessage(`{"message": ""}`),
|
Response: json.RawMessage(`{"message": ""}`),
|
||||||
},
|
},
|
||||||
"POST /api/collections/:id/rules": {
|
|
||||||
// First rule converts a static collection to smart.
|
|
||||||
Request: handlers.AddRuleRequest{},
|
|
||||||
Response: json.RawMessage(`{
|
|
||||||
"collection_id": "", "rule": {}, "is_smart": false
|
|
||||||
}`),
|
|
||||||
},
|
|
||||||
"DELETE /api/collections/:id/rules/:ruleId": {
|
|
||||||
Response: json.RawMessage(`{
|
|
||||||
"collection_id": "", "is_smart": false
|
|
||||||
}`),
|
|
||||||
},
|
|
||||||
"POST /api/collections/test-rules": {
|
|
||||||
Request: handlers.TestRulesRequest{},
|
|
||||||
Response: json.RawMessage(`{"matches": [], "total": 0}`),
|
|
||||||
},
|
|
||||||
"POST /api/collections/:id/books": {
|
"POST /api/collections/:id/books": {
|
||||||
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
|
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
|
||||||
Request: json.RawMessage(`{
|
Request: json.RawMessage(`{
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUIControlLabels holds the UI documentation to the source of the UI.
|
||||||
|
// The glossary (docs/developer/ui-controls.md) is the canonical spelling of
|
||||||
|
// every control the docs cite; this test greps that spelling against the
|
||||||
|
// server-rendered templates, so a renamed button fails make test in the
|
||||||
|
// same commit as the rename — the class of rot screenshots could never be
|
||||||
|
// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also`
|
||||||
|
// fragments instead of requiring the composed string to exist.
|
||||||
|
//
|
||||||
|
// Scope is substring-level (not word-boundary): the point is catching
|
||||||
|
// renames and deletions, not stopping coincidental substrings. New controls
|
||||||
|
// cited in prose get a glossary entry in the same commit (writing-ui-docs.md
|
||||||
|
// is the standard; the filter docs are the hands-off exemplar).
|
||||||
|
|
||||||
|
const (
|
||||||
|
labelGlossaryRel = "../../docs/developer/ui-controls.md"
|
||||||
|
labelTemplRootRel = "../../templates"
|
||||||
|
labelTSRootRel = "../../web/src"
|
||||||
|
)
|
||||||
|
|
||||||
|
type docGlossaryEntry struct {
|
||||||
|
label string
|
||||||
|
where string
|
||||||
|
does string
|
||||||
|
source string
|
||||||
|
reason string
|
||||||
|
also []string
|
||||||
|
skip bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// docCorpus concatenates every file under root ending in suffix.
|
||||||
|
func docCorpus(t *testing.T, root string, suffix string) string {
|
||||||
|
t.Helper()
|
||||||
|
abs, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("abs %s: %v", root, err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, suffix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.Write(raw)
|
||||||
|
b.WriteString("\n")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk %s: %v", root, err)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func corpusName(source string) string {
|
||||||
|
if source == "ts" {
|
||||||
|
return "web/src"
|
||||||
|
}
|
||||||
|
return "templ"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var block strings.Builder
|
||||||
|
inBlock, foundAny := false, false
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "```controls"):
|
||||||
|
inBlock, foundAny = true, true
|
||||||
|
continue
|
||||||
|
case trimmed == "```" && inBlock:
|
||||||
|
inBlock = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inBlock {
|
||||||
|
block.WriteString(line)
|
||||||
|
block.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundAny {
|
||||||
|
t.Fatalf("no ```controls block found in %s", labelGlossaryRel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []docGlossaryEntry
|
||||||
|
haveCur := false
|
||||||
|
cur := docGlossaryEntry{}
|
||||||
|
flush := func() {
|
||||||
|
if haveCur {
|
||||||
|
entries = append(entries, cur)
|
||||||
|
haveCur = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(block.String(), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("malformed controls line %q (want `key: value`)", line)
|
||||||
|
}
|
||||||
|
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
|
||||||
|
switch key {
|
||||||
|
case "label":
|
||||||
|
flush()
|
||||||
|
haveCur = true
|
||||||
|
cur = docGlossaryEntry{label: val}
|
||||||
|
case "where":
|
||||||
|
cur.where = val
|
||||||
|
case "does":
|
||||||
|
cur.does = val
|
||||||
|
case "source":
|
||||||
|
cur.source = val
|
||||||
|
case "also":
|
||||||
|
for _, frag := range strings.Split(val, "|") {
|
||||||
|
if frag = strings.TrimSpace(frag); frag != "" {
|
||||||
|
cur.also = append(cur.also, frag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "skip":
|
||||||
|
cur.skip = val == "true"
|
||||||
|
case "reason":
|
||||||
|
cur.reason = val
|
||||||
|
case "note":
|
||||||
|
// free text for humans
|
||||||
|
default:
|
||||||
|
t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIControlLabels(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(labelGlossaryRel)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read glossary: %v", err)
|
||||||
|
}
|
||||||
|
entries := parseUIControlsBlock(t, string(raw))
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatalf("glossary has no entries — add at least one before citing controls")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmplRequired, tsRequired := false, false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.source == "ts" {
|
||||||
|
tsRequired = true
|
||||||
|
} else {
|
||||||
|
tmplRequired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
corpora := map[string]string{}
|
||||||
|
if tmplRequired {
|
||||||
|
corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ")
|
||||||
|
}
|
||||||
|
if tsRequired {
|
||||||
|
corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.label == "" {
|
||||||
|
t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e.label]++
|
||||||
|
if e.where == "" || e.does == "" {
|
||||||
|
t.Errorf("control %q: `where` and `does` are required", e.label)
|
||||||
|
}
|
||||||
|
if e.skip && e.reason == "" {
|
||||||
|
t.Errorf("control %q: skip=true requires a reason", e.label)
|
||||||
|
}
|
||||||
|
switch e.source {
|
||||||
|
case "", "templ", "ts":
|
||||||
|
default:
|
||||||
|
t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source)
|
||||||
|
}
|
||||||
|
if e.skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fragments := append([]string{e.label}, e.also...)
|
||||||
|
found := false
|
||||||
|
for _, frag := range fragments {
|
||||||
|
if strings.Contains(corpora[corpusName(e.source)], frag) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.",
|
||||||
|
e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for label, n := range seen {
|
||||||
|
if n > 1 {
|
||||||
|
t.Errorf("control %q listed %d times — one entry per control", label, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/setupstatus"
|
"bookhoard/internal/setupstatus"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -122,9 +124,15 @@ type UserProfile struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Theme string `json:"theme,omitempty"`
|
Theme string `json:"theme,omitempty"`
|
||||||
// User-level default audiobook playback speed (plan §8); 0 omits.
|
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
|
// Response-only struct: omit nothing — default_audio_speed is NOT NULL
|
||||||
// Whether the web reader/player should hold a Screen Wake Lock.
|
// DEFAULT 1.0 (writes validate 0.25–4), so a real profile always emits
|
||||||
|
// it; omitempty here would drop a contract field from the docs gate.
|
||||||
|
DefaultAudioSpeed float64 `json:"default_audio_speed"` // Whether the web reader/player should hold a Screen Wake Lock.
|
||||||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
KeepScreenAwake bool `json:"keep_screen_awake"`
|
||||||
|
// Resume rewind: seconds before the saved position the audio
|
||||||
|
// player starts (open-resume and every genuine unpause). 0 is a
|
||||||
|
// real value (exact resume) — never omitted.
|
||||||
|
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileUpdateRequest struct {
|
type ProfileUpdateRequest struct {
|
||||||
@@ -141,6 +149,11 @@ type ProfileUpdateRequest struct {
|
|||||||
// Per the SPEEDS list bounds; the profile select always sends it,
|
// Per the SPEEDS list bounds; the profile select always sends it,
|
||||||
// JSON clients omit it (0 = untouched).
|
// JSON clients omit it (0 = untouched).
|
||||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||||
|
// Resume rewind seconds, as the form select sends it ("0"/"5"/"10").
|
||||||
|
// The string shape (keep_screen_awake precedent) lets the form
|
||||||
|
// select an explicit 0=Off while JSON clients that omit it decode
|
||||||
|
// as "" and leave the column untouched.
|
||||||
|
AudioResumeRewind string `json:"audio_resume_rewind_seconds,omitempty" form:"audio_resume_rewind_seconds" validate:"omitempty,oneof=0 5 10"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register handles POST /api/auth/register
|
// Register handles POST /api/auth/register
|
||||||
@@ -550,6 +563,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
|||||||
Theme: user.Theme.String,
|
Theme: user.Theme.String,
|
||||||
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
|
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
|
||||||
KeepScreenAwake: user.KeepScreenAwake,
|
KeepScreenAwake: user.KeepScreenAwake,
|
||||||
|
AudioResumeRewind: int(user.AudioResumeRewindSeconds),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,6 +728,24 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume-rewind preference. The form select always sends it
|
||||||
|
// ("0"/"5"/"10"); JSON clients that omit it decode as "" and leave
|
||||||
|
// the column untouched. Strict allow-list — the three choices the
|
||||||
|
// UI offers are the only values the API accepts.
|
||||||
|
if req.AudioResumeRewind != "" {
|
||||||
|
rewind, err := strconv.Atoi(req.AudioResumeRewind)
|
||||||
|
if err != nil || (rewind != 0 && rewind != 5 && rewind != 10) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "audio_resume_rewind_seconds must be 0, 5, or 10"})
|
||||||
|
}
|
||||||
|
err = h.db.UpdateUserAudioResumeRewind(c.Request().Context(), database.UpdateUserAudioResumeRewindParams{
|
||||||
|
ID: targetUserUUID,
|
||||||
|
AudioResumeRewindSeconds: int32(rewind),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keep-screen-awake preference. The form dropdown always sends it
|
// Keep-screen-awake preference. The form dropdown always sends it
|
||||||
// ("true"/"false"); JSON clients that omit it decode as "" and leave
|
// ("true"/"false"); JSON clients that omit it decode as "" and leave
|
||||||
// the column untouched.
|
// the column untouched.
|
||||||
@@ -1145,6 +1177,11 @@ func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
|
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||||
|
// Registration runs BEFORE the JWT exists — the request context
|
||||||
|
// carries no identity, and the collections RLS insert policy
|
||||||
|
// (2026-10-31) would reject the five system-collection seeds.
|
||||||
|
// Declare the new user as the actor: owner == actor, policy passes.
|
||||||
|
ctx = rls.WithUser(ctx, userID)
|
||||||
defaultCollections := []struct {
|
defaultCollections := []struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ type BookInfo struct {
|
|||||||
// it empty rather than pay a join).
|
// it empty rather than pay a join).
|
||||||
ViewerPercentage *float64 `json:"viewer_percentage"`
|
ViewerPercentage *float64 `json:"viewer_percentage"`
|
||||||
LibraryName string `json:"library_name"`
|
LibraryName string `json:"library_name"`
|
||||||
|
// Episode-collections extras (plan item 2.3): populated only by the
|
||||||
|
// collection-detail adapter. IsEpisode renders the episode-row
|
||||||
|
// variant in the grid; FeedTitle disambiguates same-named episodes;
|
||||||
|
// DownloadState gates the play affordance. Hidden=true means the
|
||||||
|
// LEFT JOIN missed under RLS (unsubscribed episode, or a
|
||||||
|
// family-lens hidden book) — the row renders as an "unavailable"
|
||||||
|
// hint instead of silently vanishing. Adapters that don't load
|
||||||
|
// visibility leave Hidden false (cards stay fully rendered). The
|
||||||
|
// four keys are NOT omitempty: every BookInfo surface emits the
|
||||||
|
// same stable key set (the viewer_percentage precedent —
|
||||||
|
// contract-pin widening, docs/contract_pins.md 2026-10-30).
|
||||||
|
IsEpisode bool `json:"is_episode"`
|
||||||
|
FeedTitle string `json:"feed_title"`
|
||||||
|
DownloadState string `json:"download_state"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
||||||
@@ -151,8 +166,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo
|
|||||||
ID: uuid.UUID(mi.ID.Bytes),
|
ID: uuid.UUID(mi.ID.Bytes),
|
||||||
Title: mi.Title,
|
Title: mi.Title,
|
||||||
Author: utils.PgTextString(mi.Author),
|
Author: utils.PgTextString(mi.Author),
|
||||||
LibraryID: mi.LibraryID,
|
LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath,
|
||||||
CoverPath: mi.CoverImagePath,
|
|
||||||
FormatGroup: mi.FormatGroup,
|
FormatGroup: mi.FormatGroup,
|
||||||
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
|
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
|
||||||
}))
|
}))
|
||||||
@@ -180,6 +194,39 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
|
|||||||
return cards
|
return cards
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BookCardsFromCollectionDetailed builds cards from the
|
||||||
|
// GetCollectionItemsDetailed rows (plan item 2.3): the collection's
|
||||||
|
// full membership with per-row visibility. RLS-invisible rows
|
||||||
|
// (unsubscribed episode, family-lens hidden book) become Hidden cards
|
||||||
|
// — the grid renders them as "unavailable" hint rows; episode rows
|
||||||
|
// carry feed title + download state for the play affordance. Unknown
|
||||||
|
// library types resolve no badge; hidden rows need none anyway.
|
||||||
|
func BookCardsFromCollectionDetailed(rows []database.GetCollectionItemsDetailedRow, libTypes map[string]string) []BookInfo {
|
||||||
|
cards := make([]BookInfo, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if !r.Visible {
|
||||||
|
cards = append(cards, BookInfo{
|
||||||
|
MediaItemID: uuid.UUID(r.MediaItemID.Bytes).String(),
|
||||||
|
Hidden: true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isEpisode := r.Source.String == "podcast_episode"
|
||||||
|
card := buildBookCard(cardSource{
|
||||||
|
ID: uuid.UUID(r.MediaItemID.Bytes),
|
||||||
|
Title: utils.PgTextString(r.Title),
|
||||||
|
Author: utils.PgTextString(r.Author),
|
||||||
|
CoverPath: r.CoverImagePath,
|
||||||
|
FormatGroup: r.FormatGroup.String,
|
||||||
|
})
|
||||||
|
card.IsEpisode = isEpisode
|
||||||
|
card.FeedTitle = r.FeedTitle.String
|
||||||
|
card.DownloadState = r.DownloadState.String
|
||||||
|
cards = append(cards, card)
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
// BookCardsFromUnified builds cards from search-unified rows. Those
|
// BookCardsFromUnified builds cards from search-unified rows. Those
|
||||||
// rows carry the library type name as a SQL column, so this adapter
|
// rows carry the library type name as a SQL column, so this adapter
|
||||||
// needs no LibraryTypeMap query at all — and the viewer's own
|
// needs no LibraryTypeMap query at all — and the viewer's own
|
||||||
|
|||||||
+314
-288
@@ -9,7 +9,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,10 +25,10 @@ type CollectionHandler struct {
|
|||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
|
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler {
|
||||||
return &CollectionHandler{
|
return &CollectionHandler{
|
||||||
db: db,
|
db: db,
|
||||||
collectionService: services.NewCollectionService(db),
|
collectionService: services.NewCollectionService(db, search...),
|
||||||
libraryService: libraryService,
|
libraryService: libraryService,
|
||||||
connManager: connManager,
|
connManager: connManager,
|
||||||
}
|
}
|
||||||
@@ -40,17 +39,113 @@ type CreateCollectionRequest struct {
|
|||||||
Description string `form:"description" json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
Color string `form:"color" json:"color"`
|
Color string `form:"color" json:"color"`
|
||||||
Icon string `form:"icon" json:"icon"`
|
Icon string `form:"icon" json:"icon"`
|
||||||
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart"
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
// Custom-section builder payload (adapted 2026-10-31): the page
|
||||||
|
// posts the legacy rules vocabulary — rules as a JSON-encoded array
|
||||||
|
// (a string), a match_type select, hand-picked book ids, and the
|
||||||
|
// dashboard pin. The server converts rules → filter tree at write
|
||||||
|
// time and attaches the manual picks; storage stays tree-only.
|
||||||
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
ManualBookIDs []string `json:"manual_book_ids"`
|
||||||
|
LibraryID string `json:"library_id"` // selection context only — not stored
|
||||||
|
ShowOnDashboard bool `json:"show_on_dashboard"`
|
||||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
|
// The unified filter tree (item 3.2): when present the collection's
|
||||||
|
// smart membership runs through the qf engine. INVALID trees are a
|
||||||
|
// 400 (ParseFilterTree at the handler); a tree supersedes
|
||||||
|
// auto_assign_rules. Persisted as-is (any). RawMessage for
|
||||||
|
// round-trip safety. JSON bodies bind FilterTree; FORM bodies
|
||||||
|
// (the create modal's urlencoded hx-post) bind FilterTreeForm —
|
||||||
|
// echo's binder cannot set an any-typed field from a form string
|
||||||
|
// (the create-modal seed POST 400'd "invalid request" on it,
|
||||||
|
// 2026-10-31).
|
||||||
|
FilterTree any `json:"filter_tree"`
|
||||||
|
// FilterTreeForm mirrors filter_tree for application/x-www-form-
|
||||||
|
// urlencoded submissions: the raw JSON as a string, validated by
|
||||||
|
// the same parseRequestFilterTree path.
|
||||||
|
FilterTreeForm string `form:"filter_tree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBuilderRules decodes the custom-section builder's rules payload:
|
||||||
|
// the page posts JSON.stringify(rules) — a JSON string CONTAINING the
|
||||||
|
// array (it has done so since the page was written, 2026-02-19) — while
|
||||||
|
// JSON clients may post the array directly. Null/empty = no rules.
|
||||||
|
func parseBuilderRules(raw json.RawMessage) ([]services.Rule, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rules []services.Rule
|
||||||
|
if raw[0] == '"' {
|
||||||
|
var encoded string
|
||||||
|
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(encoded) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return rules, json.Unmarshal([]byte(encoded), &rules)
|
||||||
|
}
|
||||||
|
return rules, json.Unmarshal(raw, &rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeFilterJSON returns the request's filter tree as JSON storage
|
||||||
|
// bytes (nil when none): object payloads marshal; strings (a legacy
|
||||||
|
// double-encoded client, the custom-section builder's known bug class)
|
||||||
|
// are written verbatim after object-attempt.
|
||||||
|
func treeFilterJSON(payload any) ([]byte, error) {
|
||||||
|
switch v := payload.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil, nil
|
||||||
|
case map[string]interface{}:
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
case string:
|
||||||
|
return []byte(v), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("filter_tree must be an object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRequestFilterTree validates the request's tree against the
|
||||||
|
// engine grammar (ParseFilterTree) so an invalid tree is a 400, never
|
||||||
|
// a stored 500 at render time.
|
||||||
|
func parseRequestFilterTree(payload any) ([]byte, error) {
|
||||||
|
b, err := treeFilterJSON(payload)
|
||||||
|
if err != nil || b == nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
if _, err := services.ParseFilterTree(string(b)); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid filter_tree: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTreePayload — the tree payload from either transport: JSON
|
||||||
|
// bodies carry the object in FilterTree, form bodies (the create
|
||||||
|
// modal's urlencoded hx-post) the raw JSON string in FilterTreeForm.
|
||||||
|
func resolveTreePayload(jsonTree any, formTree string) any {
|
||||||
|
if formTree != "" {
|
||||||
|
return formTree
|
||||||
|
}
|
||||||
|
return jsonTree
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCollectionRequest struct {
|
type UpdateCollectionRequest struct {
|
||||||
Name string `form:"name" json:"name" validate:"required"`
|
Name string `form:"name" json:"name" validate:"required"`
|
||||||
|
// The unified filter tree (item 3.2): when present the collection's
|
||||||
|
// smart membership runs through the qf engine. INVALID trees are a
|
||||||
|
// 400 (ParseFilterTree at the handler). JSON bodies bind FilterTree; form bodies bind
|
||||||
|
// FilterTreeForm (echo's binder cannot set an any-typed field from
|
||||||
|
// a form string — see CreateCollectionRequest).
|
||||||
|
FilterTree any `json:"filter_tree"`
|
||||||
|
FilterTreeForm string `form:"filter_tree"`
|
||||||
Description string `form:"description" json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
Color string `form:"color" json:"color"`
|
Color string `form:"color" json:"color"`
|
||||||
Icon string `form:"icon" json:"icon"`
|
Icon string `form:"icon" json:"icon"`
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
|
||||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
||||||
// (nil = untouched). []string because the form posts a hidden "false"
|
// (nil = untouched). []string because the form posts a hidden "false"
|
||||||
@@ -94,7 +189,6 @@ type CollectionSummary struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
BookCount int `json:"book_count"`
|
BookCount int `json:"book_count"`
|
||||||
IsSmart bool `json:"is_smart"`
|
IsSmart bool `json:"is_smart"`
|
||||||
@@ -138,6 +232,55 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom-section builder adapter: convert the posted rules to a
|
||||||
|
// filter tree at WRITE time (the save-fix — the page's payload had
|
||||||
|
// never once bound successfully before this).
|
||||||
|
builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules)
|
||||||
|
if rulesErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_assign_rules: " + rulesErr.Error()})
|
||||||
|
}
|
||||||
|
matchMode := req.MatchType
|
||||||
|
if matchMode != services.RuleMatchAll && matchMode != services.RuleMatchAny {
|
||||||
|
matchMode = services.RuleMatchAny
|
||||||
|
}
|
||||||
|
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
|
||||||
|
if treeErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
||||||
|
}
|
||||||
|
if treeJSON == nil && len(builderRules) > 0 {
|
||||||
|
tree, cerr := services.RulesToFilterTree(builderRules, matchMode)
|
||||||
|
if cerr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + cerr.Error()})
|
||||||
|
}
|
||||||
|
b, merr := json.Marshal(tree)
|
||||||
|
if merr != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": merr.Error()})
|
||||||
|
}
|
||||||
|
// The strict-parse guarantee holds for converted trees too: an
|
||||||
|
// invalid tree is a 400 at write, never a stored 500 at render.
|
||||||
|
if _, verr := services.ParseFilterTree(string(b)); verr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + verr.Error()})
|
||||||
|
}
|
||||||
|
treeJSON = b
|
||||||
|
}
|
||||||
|
// New podcast smart collections are BORN with the reversed listening
|
||||||
|
// default (2026-11-01 user request): seed chain_order into
|
||||||
|
// view_settings at creation — the dropdown shows "Feed order
|
||||||
|
// (reversed)" selected and the chain/page machinery reads the stored
|
||||||
|
// value exactly as if picked. Episodes-scope trees only (books have
|
||||||
|
// no episode chain); merge semantics never clobber a value the
|
||||||
|
// request itself sent; existing collections are untouched.
|
||||||
|
if treeJSON != nil {
|
||||||
|
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
|
||||||
|
t.FilterScope() == services.ScopeEpisodes {
|
||||||
|
if req.ViewSettings == nil {
|
||||||
|
req.ViewSettings = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if _, exists := req.ViewSettings["chain_order"]; !exists {
|
||||||
|
req.ViewSettings["chain_order"] = "reverse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
collection, err := h.collectionService.CreateCollection(
|
collection, err := h.collectionService.CreateCollection(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
userUUID,
|
userUUID,
|
||||||
@@ -145,8 +288,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
req.Description,
|
req.Description,
|
||||||
req.Color,
|
req.Color,
|
||||||
req.Icon,
|
req.Icon,
|
||||||
req.AutoAssignRules,
|
|
||||||
req.ViewSettings,
|
req.ViewSettings,
|
||||||
|
req.ShowOnDashboard,
|
||||||
|
treeJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, services.ErrCollectionNameTaken) {
|
if errors.Is(err, services.ErrCollectionNameTaken) {
|
||||||
@@ -155,8 +299,19 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual picks: a section = rules + hand-picked books. Attach after
|
||||||
|
// create; unparsable ids are skipped (same leniency as AddBooks).
|
||||||
|
for _, bookIDStr := range req.ManualBookIDs {
|
||||||
|
bookID, perr := uuid.Parse(bookIDStr)
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = h.collectionService.AddBookToCollection(c.Request().Context(),
|
||||||
|
uuid.UUID(collection.ID.Bytes), bookID, userUUID)
|
||||||
|
}
|
||||||
|
|
||||||
bookCount := int32(0)
|
bookCount := int32(0)
|
||||||
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
|
isSmart := req.CollectionType == "smart" || treeJSON != nil
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
@@ -165,7 +320,6 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
||||||
"auto_assign_rules": collection.AutoAssignRules,
|
|
||||||
"view_settings": collection.ViewSettings,
|
"view_settings": collection.ViewSettings,
|
||||||
"book_count": bookCount,
|
"book_count": bookCount,
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
@@ -197,7 +351,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
|
|
||||||
response := make([]CollectionSummary, 0, len(collections))
|
response := make([]CollectionSummary, 0, len(collections))
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
isSmart := services.IsCollectionSmart(col)
|
||||||
if !includeAuto && isSmart {
|
if !includeAuto && isSmart {
|
||||||
// Legacy default for API clients (the Android app pre-dates
|
// Legacy default for API clients (the Android app pre-dates
|
||||||
// smart collections): rule-driven collections stay opt-in so
|
// smart collections): rule-driven collections stay opt-in so
|
||||||
@@ -213,12 +367,12 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isSmart {
|
if isSmart {
|
||||||
rules := h.collectionService.ParseRules(col.AutoAssignRules)
|
|
||||||
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||||
if visErr == nil {
|
if visErr == nil {
|
||||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||||
})
|
})
|
||||||
if evalErr == nil {
|
if evalErr == nil {
|
||||||
bookCount = len(matches)
|
bookCount = len(matches)
|
||||||
@@ -240,7 +394,6 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
Description: utils.PgTextString(col.Description),
|
Description: utils.PgTextString(col.Description),
|
||||||
Color: utils.PgTextString(col.Color),
|
Color: utils.PgTextString(col.Color),
|
||||||
Icon: utils.PgTextString(col.Icon),
|
Icon: utils.PgTextString(col.Icon),
|
||||||
AutoAssignRules: col.AutoAssignRules,
|
|
||||||
CreatedAt: col.CreatedAt.Time.String(),
|
CreatedAt: col.CreatedAt.Time.String(),
|
||||||
BookCount: bookCount,
|
BookCount: bookCount,
|
||||||
IsSmart: isSmart,
|
IsSmart: isSmart,
|
||||||
@@ -286,17 +439,17 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
|
isSmart := services.IsCollectionSmart(collection)
|
||||||
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
||||||
if isSmart {
|
if isSmart {
|
||||||
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
|
|
||||||
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||||
})
|
})
|
||||||
if evalErr != nil {
|
if evalErr != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
||||||
@@ -305,7 +458,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
dashboardSvc := services.NewDashboardService(h.db)
|
dashboardSvc := services.NewDashboardService(h.db, h.collectionService.Search())
|
||||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||||
if secErr != nil {
|
if secErr != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
||||||
@@ -409,8 +562,8 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
"description": utils.PgTextString(collection.Description),
|
"description": utils.PgTextString(collection.Description),
|
||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
|
||||||
"view_settings": viewSettings,
|
"view_settings": viewSettings,
|
||||||
|
"filter_tree": treeEnvelope(collection.FilterTree),
|
||||||
"books": bookList,
|
"books": bookList,
|
||||||
"book_count": len(bookList),
|
"book_count": len(bookList),
|
||||||
"is_smart": isSmart,
|
"is_smart": isSmart,
|
||||||
@@ -450,6 +603,31 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
req.ViewSettings["show_up_next"] = *v
|
req.ViewSettings["show_up_next"] = *v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
|
||||||
|
if treeErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
||||||
|
}
|
||||||
|
// Applying a tree to a collection with NO curated listening order
|
||||||
|
// seeds the reversed default (2026-11-01 — the creation seed's twin):
|
||||||
|
// a static collection becoming smart lands with the same default a
|
||||||
|
// born-smart one gets. Triple-guarded: the client's explicit
|
||||||
|
// chain_order wins, a STORED chain_order is never clobbered, and
|
||||||
|
// only episodes-scope trees seed (books have no episode chain).
|
||||||
|
if treeJSON != nil && (req.ViewSettings == nil || req.ViewSettings["chain_order"] == nil) {
|
||||||
|
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
|
||||||
|
t.FilterScope() == services.ScopeEpisodes {
|
||||||
|
if existing, gerr := h.collectionService.GetCollection(c.Request().Context(), collectionID); gerr == nil {
|
||||||
|
var stored map[string]interface{}
|
||||||
|
_ = json.Unmarshal(existing.ViewSettings, &stored)
|
||||||
|
if stored == nil || stored["chain_order"] == nil {
|
||||||
|
if req.ViewSettings == nil {
|
||||||
|
req.ViewSettings = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
req.ViewSettings["chain_order"] = "reverse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
collection, err := h.collectionService.UpdateCollection(
|
collection, err := h.collectionService.UpdateCollection(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
collectionID,
|
collectionID,
|
||||||
@@ -457,10 +635,16 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
req.Description,
|
req.Description,
|
||||||
req.Color,
|
req.Color,
|
||||||
req.Icon,
|
req.Icon,
|
||||||
req.AutoAssignRules,
|
|
||||||
req.ViewSettings,
|
req.ViewSettings,
|
||||||
|
treeJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Collections RLS: another user's collection is INVISIBLE —
|
||||||
|
// the update touched no rows. Answer 404, not a 500 that
|
||||||
|
// reads like a bug (2026-10-31 collections RLS).
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +655,6 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
"description": utils.PgTextString(collection.Description),
|
"description": utils.PgTextString(collection.Description),
|
||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
|
||||||
"view_settings": json.RawMessage(collection.ViewSettings),
|
"view_settings": json.RawMessage(collection.ViewSettings),
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
}
|
}
|
||||||
@@ -484,6 +667,99 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusCreated, response)
|
return c.JSON(http.StatusCreated, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidChainOrders — the collection chain's listening-order vocabulary
|
||||||
|
// (plan §10 R2: a collection's playback order is CURATED on the
|
||||||
|
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
|
||||||
|
// "" means native (the pre-chain-order default).
|
||||||
|
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
|
||||||
|
|
||||||
|
// CollectionChainOrder resolves a collection row's curated chain order
|
||||||
|
// from its view_settings JSONB — "" (native) when absent/unknown. The
|
||||||
|
// episode chain producer routes OrderEpisodes through this; the
|
||||||
|
// view-settings PUT writes the key (SetCollectionChainOrder).
|
||||||
|
func CollectionChainOrder(viewSettings []byte) string {
|
||||||
|
if len(viewSettings) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var vs map[string]interface{}
|
||||||
|
if json.Unmarshal(viewSettings, &vs) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
|
||||||
|
// {"chain_order": "..."}: merges the key into the collection's stored
|
||||||
|
// view_settings (the playlist toggles share that JSONB — a blind write
|
||||||
|
// would clobber them). The episode chain producer
|
||||||
|
// (buildSmartCollectionSequenceContext) honors it.
|
||||||
|
func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ChainOrder string `json:"chain_order"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if !ValidChainOrders[req.ChainOrder] {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "chain_order must be one of: native, newest, oldest, reverse"})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
|
}
|
||||||
|
// Same owner gate as the sibling config mutation (ReorderItems) —
|
||||||
|
// my endpoint writes the row, so it must not let a non-owner flip
|
||||||
|
// someone else's chain order.
|
||||||
|
if u, ok := c.Get("user").(database.Users); ok {
|
||||||
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes))
|
||||||
|
if err != nil || !owns {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "no user in context"})
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := map[string]interface{}{}
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.ChainOrder == "" || req.ChainOrder == "native" {
|
||||||
|
// Both spellings mean the default — drop the key entirely so the
|
||||||
|
// stored doc stays clean.
|
||||||
|
delete(settings, "chain_order")
|
||||||
|
} else {
|
||||||
|
settings["chain_order"] = req.ChainOrder
|
||||||
|
}
|
||||||
|
blob, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
|
||||||
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
ViewSettings: blob,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
|
"chain_order": req.ChainOrder,
|
||||||
|
"view_settings": settings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -851,278 +1127,25 @@ func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
||||||
}
|
}
|
||||||
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
|
if services.IsCollectionSmart(collection) {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
|
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are tree-based — membership is managed by the collection's filter tree, not by hand")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddRuleRequest struct {
|
// treeEnvelope renders the stored filter tree for the envelope: nil
|
||||||
Field string `json:"field" validate:"required"`
|
// (static collections) serializes as null, trees as the object.
|
||||||
Operator string `json:"operator" validate:"required"`
|
func treeEnvelope(t []byte) any {
|
||||||
Value string `json:"value" validate:"required"`
|
if len(t) == 0 {
|
||||||
Enabled *bool `json:"enabled"`
|
return nil
|
||||||
Priority int `json:"priority"`
|
}
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal(t, &v) == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRule handles POST /api/collections/:id/rules — appends one rule to
|
|
||||||
// the ruleset. The first rule converts a static collection to smart.
|
|
||||||
func (h *CollectionHandler) AddRule(c *echo.Context) error {
|
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
||||||
}
|
|
||||||
|
|
||||||
collection, err := h.GetCollectionData(c, collectionID)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
|
||||||
}
|
|
||||||
if collection.IsSystemCollection.Bool {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req AddRuleRequest
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
||||||
}
|
|
||||||
if err := c.Validate(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
|
|
||||||
Field: req.Field,
|
|
||||||
Operator: req.Operator,
|
|
||||||
Value: req.Value,
|
|
||||||
Enabled: req.Enabled,
|
|
||||||
Priority: req.Priority,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
var ruleErr *services.RuleValidationError
|
|
||||||
if errors.As(err, &ruleErr) {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
|
||||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
||||||
"rule": rule,
|
|
||||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
|
|
||||||
// the enabled toggle; the payload may only carry {"enabled": bool}.
|
|
||||||
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
|
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
||||||
}
|
|
||||||
ruleID := c.Param("ruleId")
|
|
||||||
if ruleID == "" {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Enabled *bool `json:"enabled" validate:"required"`
|
|
||||||
}
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
||||||
}
|
|
||||||
if err := c.Validate(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "rule not found") {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
||||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
||||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
|
|
||||||
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
||||||
}
|
|
||||||
ruleID := c.Param("ruleId")
|
|
||||||
if ruleID == "" {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "rule not found") {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
||||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
||||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestRulesRequest struct {
|
|
||||||
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BookMatch struct {
|
|
||||||
MediaItemID string `json:"media_item_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
|
||||||
MatchReason string `json:"match_reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
|
||||||
var req TestRulesRequest
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
||||||
}
|
|
||||||
if err := c.Validate(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Candidates mirror what smart collections actually evaluate against:
|
|
||||||
// the requester's visible libraries and live items only — the preview
|
|
||||||
// must not promise matches the live render will never produce.
|
|
||||||
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
var visibleArg []pgtype.UUID
|
|
||||||
if len(visibleIDs) > 0 {
|
|
||||||
visibleArg = visibleIDs
|
|
||||||
}
|
|
||||||
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
|
|
||||||
LibraryID: pgtype.UUID{},
|
|
||||||
VisibleLibraryIds: visibleArg,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var matches []BookMatch
|
|
||||||
for _, item := range mediaItems {
|
|
||||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
|
||||||
if matchReason != "" {
|
|
||||||
author := ""
|
|
||||||
if item.Author.Valid {
|
|
||||||
author = item.Author.String
|
|
||||||
}
|
|
||||||
|
|
||||||
matches = append(matches, BookMatch{
|
|
||||||
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
|
|
||||||
Title: item.Title,
|
|
||||||
Author: author,
|
|
||||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
||||||
MatchReason: matchReason,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
||||||
"matches": matches,
|
|
||||||
"total": len(matches),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, rules []map[string]interface{}) string {
|
|
||||||
for _, rule := range rules {
|
|
||||||
field, _ := rule["field"].(string)
|
|
||||||
operator, _ := rule["operator"].(string)
|
|
||||||
value, _ := rule["value"].(string)
|
|
||||||
|
|
||||||
if h.evaluateRule(item, field, operator, value) {
|
|
||||||
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) evaluateRule(item database.MediaItems, field, operator, value string) bool {
|
|
||||||
var itemValue string
|
|
||||||
|
|
||||||
switch field {
|
|
||||||
case "genre":
|
|
||||||
if item.Genre.Valid {
|
|
||||||
itemValue = item.Genre.String
|
|
||||||
}
|
|
||||||
case "series":
|
|
||||||
if item.Series.Valid {
|
|
||||||
itemValue = item.Series.String
|
|
||||||
}
|
|
||||||
case "author":
|
|
||||||
if item.Author.Valid {
|
|
||||||
itemValue = item.Author.String
|
|
||||||
}
|
|
||||||
case "language":
|
|
||||||
if item.Language.Valid {
|
|
||||||
itemValue = item.Language.String
|
|
||||||
}
|
|
||||||
case "publisher":
|
|
||||||
if item.Publisher.Valid {
|
|
||||||
itemValue = item.Publisher.String
|
|
||||||
}
|
|
||||||
case "copyright_year":
|
|
||||||
if item.CopyrightYear.Valid {
|
|
||||||
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
|
||||||
}
|
|
||||||
case "tags":
|
|
||||||
if len(item.Tags) > 0 {
|
|
||||||
itemValue = strings.Join(item.Tags, ", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.compareValues(itemValue, operator, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
|
|
||||||
switch operator {
|
|
||||||
case "equals":
|
|
||||||
return itemValue == ruleValue
|
|
||||||
case "not_equals":
|
|
||||||
return itemValue != ruleValue
|
|
||||||
case "contains":
|
|
||||||
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "not_contains":
|
|
||||||
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "starts_with":
|
|
||||||
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "ends_with":
|
|
||||||
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
||||||
case "greater_than":
|
|
||||||
itemNum, err1 := strconv.Atoi(itemValue)
|
|
||||||
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
||||||
if err1 != nil || err2 != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return itemNum > ruleNum
|
|
||||||
case "less_than":
|
|
||||||
itemNum, err1 := strconv.Atoi(itemValue)
|
|
||||||
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
||||||
if err1 != nil || err2 != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return itemNum < ruleNum
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk Operations
|
|
||||||
|
|
||||||
// POST /api/collections/bulk-add-books
|
|
||||||
// Bulk add books to multiple collections
|
|
||||||
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
@@ -1268,9 +1291,12 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Book-scope preview (the rules page remains books-first until the
|
||||||
|
// filter-unification pass); no viewer progress loaded here, so
|
||||||
|
// played/unplayed rules preview at the no-progress default.
|
||||||
var matchedItems []database.MediaItems
|
var matchedItems []database.MediaItems
|
||||||
for _, item := range allItems {
|
for _, item := range allItems {
|
||||||
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
|
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
|
||||||
for _, eval := range evaluations {
|
for _, eval := range evaluations {
|
||||||
if eval.Matches {
|
if eval.Matches {
|
||||||
matchedItems = append(matchedItems, item)
|
matchedItems = append(matchedItems, item)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The collection chain's curated order (plan §10 R2): the resolver
|
||||||
|
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
|
||||||
|
// refuses unknown orders (they mean native).
|
||||||
|
func TestCollectionChainOrder(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
vs string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"absent doc", "", ""},
|
||||||
|
{"empty object", `{}`, ""},
|
||||||
|
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
|
||||||
|
{"newest", `{"chain_order":"newest"}`, "newest"},
|
||||||
|
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
|
||||||
|
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
|
||||||
|
{"non-string means native", `{"chain_order":3}`, ""},
|
||||||
|
{"corrupt doc means native", `not json`, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
|
||||||
|
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test collection rule matching with various operators
|
|
||||||
|
|
||||||
func TestEvaluateRule_Equals_StringMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Equals_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Fantasy", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NotEquals_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Fantasy", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NotEquals_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Contains_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "contains", "asimov")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Contains_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "contains", "clarke")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NotContains_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "not_contains", "clarke")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_StartsWith_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "series", "starts_with", "Foun")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_StartsWith_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "series", "starts_with", "Robots")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_EndsWith_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "series", "ends_with", "tion")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_GreaterThan_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_GreaterThan_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_LessThan_Matches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_LessThan_NoMatch(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NullField(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{Valid: false}, // NULL
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.False(t, result, "Should return false for NULL fields")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_CaseInsensitive(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Science Fiction", "contains", "science fiction")
|
|
||||||
assert.True(t, result, "Contains should be case-insensitive")
|
|
||||||
|
|
||||||
result = handler.compareValues("Science Fiction", "contains", "SCIENCE FICTION")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_EmptyString(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("", "contains", "test")
|
|
||||||
assert.False(t, result, "Empty string should not contain anything")
|
|
||||||
|
|
||||||
result = handler.compareValues("test", "contains", "")
|
|
||||||
assert.True(t, result, "Non-empty string contains empty string")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_NumericEdgeCases(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("0", "greater_than", "-1")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("9999", "less_than", "10000")
|
|
||||||
assert.True(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test rule checking with multiple rules
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_MatchesFirstRule(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.NotEmpty(t, reason, "Should match at least one rule")
|
|
||||||
assert.Contains(t, reason, "genre")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_MatchesSecondRule(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Fantasy", Valid: true},
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.NotEmpty(t, reason, "Should match second rule")
|
|
||||||
assert.Contains(t, reason, "author")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_NoMatches(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Mystery", Valid: true},
|
|
||||||
Author: pgtype.Text{String: "Agatha Christie", Valid: true},
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1950, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"field": "genre",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "Science Fiction",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "author",
|
|
||||||
"operator": "contains",
|
|
||||||
"value": "Asimov",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.Empty(t, reason, "Should not match any rules")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckRulesAgainstBook_EmptyRules(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []map[string]interface{}{}
|
|
||||||
|
|
||||||
reason := handler.checkRulesAgainstBook(item, rules)
|
|
||||||
assert.Empty(t, reason, "Empty rules should return empty reason")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test complex rule scenarios
|
|
||||||
|
|
||||||
func TestComplexRule_MultipleConditions(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
// Book: "Foundation" by Isaac Asimov, Sci-Fi, 1951
|
|
||||||
item := database.MediaItems{
|
|
||||||
Title: "Foundation",
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 1951, Valid: true},
|
|
||||||
Series: pgtype.Text{String: "Foundation", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
field string
|
|
||||||
operator string
|
|
||||||
value string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Match genre exactly",
|
|
||||||
field: "genre",
|
|
||||||
operator: "equals",
|
|
||||||
value: "Science Fiction",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match author substring",
|
|
||||||
field: "author",
|
|
||||||
operator: "contains",
|
|
||||||
value: "asimov",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match year greater than",
|
|
||||||
field: "copyright_year",
|
|
||||||
operator: "greater_than",
|
|
||||||
value: "1950",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match year less than",
|
|
||||||
field: "copyright_year",
|
|
||||||
operator: "less_than",
|
|
||||||
value: "2000",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match series starts with",
|
|
||||||
field: "series",
|
|
||||||
operator: "starts_with",
|
|
||||||
value: "Found",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Match series ends with",
|
|
||||||
field: "series",
|
|
||||||
operator: "ends_with",
|
|
||||||
value: "tion",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No match for genre",
|
|
||||||
field: "genre",
|
|
||||||
operator: "equals",
|
|
||||||
value: "Fantasy",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No match for author",
|
|
||||||
field: "author",
|
|
||||||
operator: "contains",
|
|
||||||
value: "Tolkien",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := handler.evaluateRule(item, tt.field, tt.operator, tt.value)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test edge cases and error handling
|
|
||||||
|
|
||||||
func TestEvaluateRule_InvalidOperator(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "invalid_operator", "Science Fiction")
|
|
||||||
assert.False(t, result, "Invalid operator should return false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_NonExistentField(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "nonexistent_field", "equals", "value")
|
|
||||||
assert.False(t, result, "Non-existent field should return false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_InvalidOperator(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("test", "invalid_op", "value")
|
|
||||||
assert.False(t, result, "Invalid comparison operator should return false")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_NumericConversion(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
// Valid numeric strings
|
|
||||||
result := handler.compareValues("100", "greater_than", "99")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("99", "less_than", "100")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
// Invalid numeric strings (should return false)
|
|
||||||
result = handler.compareValues("abc", "greater_than", "100")
|
|
||||||
assert.False(t, result, "Non-numeric string should not compare as greater than")
|
|
||||||
|
|
||||||
result = handler.compareValues("100", "greater_than", "xyz")
|
|
||||||
assert.False(t, result, "Non-numeric value should not allow comparison")
|
|
||||||
}
|
|
||||||
@@ -3,95 +3,12 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompareValues_Equals(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Science Fiction", "equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("Science Fiction", "equals", "Fantasy")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_Contains(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Isaac Asimov", "contains", "asimov")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("Isaac Asimov", "contains", "clarke")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_GreaterThan(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("2021", "greater_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("1999", "greater_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompareValues_NotEquals(t *testing.T) {
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.compareValues("Science Fiction", "not_equals", "Fantasy")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.compareValues("Science Fiction", "not_equals", "Science Fiction")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Genre(t *testing.T) {
|
|
||||||
item := database.MediaItems{
|
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.evaluateRule(item, "genre", "equals", "Fantasy")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_Author(t *testing.T) {
|
|
||||||
item := database.MediaItems{
|
|
||||||
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "author", "contains", "asimov")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.evaluateRule(item, "author", "contains", "clarke")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateRule_CopyrightYear(t *testing.T) {
|
|
||||||
item := database.MediaItems{
|
|
||||||
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &CollectionHandler{}
|
|
||||||
|
|
||||||
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
|
|
||||||
assert.True(t, result)
|
|
||||||
|
|
||||||
result = handler.evaluateRule(item, "copyright_year", "less_than", "2000")
|
|
||||||
assert.False(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTextToString(t *testing.T) {
|
func TestTextToString(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ type DashboardHandler struct {
|
|||||||
dashboardService *services.DashboardService
|
dashboardService *services.DashboardService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
func NewDashboardHandler(db *database.Queries, search ...*services.SearchService) *DashboardHandler {
|
||||||
return &DashboardHandler{
|
return &DashboardHandler{
|
||||||
db: db,
|
db: db,
|
||||||
dashboardService: services.NewDashboardService(db),
|
dashboardService: services.NewDashboardService(db, search...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,36 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
|
|||||||
|
|
||||||
// ExecuteSearch performs search and returns results with count
|
// ExecuteSearch performs search and returns results with count
|
||||||
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
||||||
|
// SearchService exposes the shared search engine for SSR surfaces
|
||||||
|
// that render a filter tree (the collection editor and bookshelf pages
|
||||||
|
// resolve id labels through it — 2026-11-01).
|
||||||
|
func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService }
|
||||||
|
|
||||||
|
// ResolveFilterLabels — GET /api/filter/labels?field=X&ids=a,b,c names
|
||||||
|
// id-kind filter values straight from their source tables: the client
|
||||||
|
// path that never touches a server render (applying a SAVED filter
|
||||||
|
// from a cold bookshelf) would otherwise fall back to the suggest
|
||||||
|
// facet, whose rows are media-constrained and label only the ids whose
|
||||||
|
// media matches the tree's sibling rules (2026-11-01 feed repro).
|
||||||
|
// Visibility/RLS ride the request identity, same as the SSR embed.
|
||||||
|
func (h *MediaHandler) ResolveFilterLabels(c *echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
field := c.QueryParam("field")
|
||||||
|
if !services.IsIDValuedField(field) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "field is not an id-valued filter field"})
|
||||||
|
}
|
||||||
|
ids := services.ParseUUIDValues(strings.Split(c.QueryParam("ids"), ","))
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "ids required"})
|
||||||
|
}
|
||||||
|
if len(ids) > services.MaxResolveIDs {
|
||||||
|
ids = ids[:services.MaxResolveIDs]
|
||||||
|
}
|
||||||
|
labels := h.SearchService().ResolveFieldLabels(
|
||||||
|
c.Request().Context(), user.ID, field, ids)
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"labels": labels})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||||
return h.searchService.ExecuteSearch(ctx, params)
|
return h.searchService.ExecuteSearch(ctx, params)
|
||||||
}
|
}
|
||||||
@@ -1881,6 +1911,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invariant 2 (podcast plan §10, absolute form): episode rows are
|
||||||
|
// removed ONLY when the whole feed is removed — the feed must match
|
||||||
|
// its XML until then. Same segregation as the edit path (D9/D10):
|
||||||
|
// episodes are fetcher-owned; delete FILES via the podcast surfaces
|
||||||
|
// (feed page / admin podcast view) instead.
|
||||||
|
if media.Source == "podcast_episode" {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{
|
||||||
|
"error": "podcast episodes are removed only by removing the whole feed — delete the episode's files instead (feed page or admin podcast view)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
||||||
// storage is unreachable so the cascade can't destroy annotations for
|
// storage is unreachable so the cascade can't destroy annotations for
|
||||||
// a file that will re-import on the next scan.
|
// a file that will re-import on the next scan.
|
||||||
@@ -2548,6 +2589,13 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
|||||||
tree, err := services.ParseFilterTree(qf)
|
tree, err := services.ParseFilterTree(qf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
params.FilterParseErr = err.Error()
|
params.FilterParseErr = err.Error()
|
||||||
|
} else if len(tree.Children) == 0 {
|
||||||
|
// A qf with no rules is not a browse query: the deep-link
|
||||||
|
// contract answers 400 (pinned). The empty root gained
|
||||||
|
// meaning only as a STORED collection seed — where the
|
||||||
|
// composers define it as matching nothing. As a query it
|
||||||
|
// would silently mean "everything", so reject at the wire.
|
||||||
|
params.FilterParseErr = "qf tree has no rules"
|
||||||
} else {
|
} else {
|
||||||
params.FilterTree = tree
|
params.FilterTree = tree
|
||||||
}
|
}
|
||||||
@@ -3053,14 +3101,19 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
|||||||
// 1.0 IN the payload so the player applies it with zero extra round
|
// 1.0 IN the payload so the player applies it with zero extra round
|
||||||
// trips; default_playback_speed powers the dropdown's "(default)"
|
// trips; default_playback_speed powers the dropdown's "(default)"
|
||||||
// marker. Speed is a preference, so this rides the authenticated
|
// marker. Speed is a preference, so this rides the authenticated
|
||||||
// user — anonymous readers just get 1.0.
|
// user — anonymous readers just get 1.0. The resume rewind rides
|
||||||
|
// the same user row (0 = exact resume).
|
||||||
playbackSpeed, defaultSpeed := 1.0, 1.0
|
playbackSpeed, defaultSpeed := 1.0, 1.0
|
||||||
|
resumeRewind := 0
|
||||||
if userID, ok := c.Get("user_id").(string); ok {
|
if userID, ok := c.Get("user_id").(string); ok {
|
||||||
if userUUID, err := uuid.Parse(userID); err == nil {
|
if userUUID, err := uuid.Parse(userID); err == nil {
|
||||||
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||||
if err == nil && userRow.DefaultAudioSpeed > 0 {
|
if err == nil {
|
||||||
|
if userRow.DefaultAudioSpeed > 0 {
|
||||||
defaultSpeed = float64(userRow.DefaultAudioSpeed)
|
defaultSpeed = float64(userRow.DefaultAudioSpeed)
|
||||||
}
|
}
|
||||||
|
resumeRewind = int(userRow.AudioResumeRewindSeconds)
|
||||||
|
}
|
||||||
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
|
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
@@ -3082,6 +3135,7 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
|||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
"playback_speed": playbackSpeed,
|
"playback_speed": playbackSpeed,
|
||||||
"default_playback_speed": defaultSpeed,
|
"default_playback_speed": defaultSpeed,
|
||||||
|
"resume_rewind_seconds": resumeRewind,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+183
-35
@@ -9,7 +9,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
@@ -38,8 +38,11 @@ type PodcastHandler struct {
|
|||||||
// EpisodeListRenderer renders the post-action episode list for an htmx
|
// EpisodeListRenderer renders the post-action episode list for an htmx
|
||||||
// request. includePolicySelect additionally out-of-band-swaps the
|
// request. includePolicySelect additionally out-of-band-swaps the
|
||||||
// auto-download select (Download All flips the caller's policy — the
|
// auto-download select (Download All flips the caller's policy — the
|
||||||
// dropdown must reflect it immediately, issue #3).
|
// dropdown must reflect it immediately, issue #3);
|
||||||
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect bool) error
|
// includeOrderSelect likewise rides along for the order endpoint (the
|
||||||
|
// "Reverse feed order" button flips to reverse — the dropdown must
|
||||||
|
// mirror the server state, §0 item 3).
|
||||||
|
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error
|
||||||
|
|
||||||
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
|
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
|
||||||
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
|
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
|
||||||
@@ -48,13 +51,127 @@ func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
|
|||||||
|
|
||||||
// hxEpisodeList answers an htmx action with the refreshed partial when
|
// hxEpisodeList answers an htmx action with the refreshed partial when
|
||||||
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
|
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
|
||||||
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect bool) error {
|
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
|
||||||
if h.episodeListRenderer != nil {
|
if h.episodeListRenderer != nil {
|
||||||
return h.episodeListRenderer(c, feedID, includePolicySelect)
|
return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
|
||||||
}
|
}
|
||||||
return hxAccepted(c, "")
|
return hxAccepted(c, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminDeleteFeedFiles POST /api/podcasts/feeds/:id/delete-files —
|
||||||
|
// admin-only (§0 item 8): delete every downloaded episode file of the
|
||||||
|
// feed; rows reset to pending. HX answers HX-Refresh (the admin table
|
||||||
|
// re-renders with fresh aggregates).
|
||||||
|
func (h *PodcastHandler) AdminDeleteFeedFiles(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||||
|
}
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
// media_items writes run under the first-admin identity (invariant
|
||||||
|
// 12 — same contract as the fetcher/worker).
|
||||||
|
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
reset, err := h.podcasts.AdminDeleteFeedFiles(actx, feedID)
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
log.Printf("[PODCAST] admin: deleted files for %d episode(s) of feed %s (user %s)", reset, feedID, user.Username)
|
||||||
|
if isHXRequest(c) {
|
||||||
|
c.Response().Header().Set("HX-Refresh", "true")
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteEpisodeFile POST /api/podcasts/episodes/:id/delete-file —
|
||||||
|
// admin-only (§0 item 8): ONE episode's file deleted, row reset to
|
||||||
|
// pending. HX answers with the refreshed episode list.
|
||||||
|
func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||||
|
}
|
||||||
|
episodeID, err := parseUUID(c.Param("eid"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
|
||||||
|
}
|
||||||
|
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
if err := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
log.Printf("[PODCAST] admin: deleted file of episode %s (user %s)", episodeID, user.Username)
|
||||||
|
if isHXRequest(c) {
|
||||||
|
// Re-render the episode list: the row needs its fresh state. The
|
||||||
|
// episode's feed id comes from the row we just reset.
|
||||||
|
if ep, err := h.db.GetPodcastEpisode(actx, episodeID); err == nil && ep.SourceFeedID.Valid {
|
||||||
|
return h.hxEpisodeList(c, ep.SourceFeedID.String(), false, false)
|
||||||
|
}
|
||||||
|
return hxAccepted(c, "")
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files —
|
||||||
|
// admin-only (§0 item 8): multi-select file deletion. Body carries the
|
||||||
|
// CHECKED boxes' values (form-encoded, repeated episode-file-select
|
||||||
|
// keys — hx-include gathers them; parsed directly, never bound: the
|
||||||
|
// repeated-key wire is another echo-bind gotcha). Each id is validated
|
||||||
|
// to belong to THIS feed; rows reset to pending, files removed.
|
||||||
|
func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||||
|
}
|
||||||
|
feedID, err := parseUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
|
}
|
||||||
|
if err := c.Request().ParseForm(); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
rawIDs := c.Request().PostForm["episode-file-select"]
|
||||||
|
if len(rawIDs) == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"})
|
||||||
|
}
|
||||||
|
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return podcastErrorResponse(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for _, raw := range rawIDs {
|
||||||
|
episodeID, perr := parseUUID(raw)
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The checkbox list only renders this feed's rows, but the wire
|
||||||
|
// is user-controlled: a forged id for a foreign feed's episode
|
||||||
|
// is refused (feed-match guard), not silently applied.
|
||||||
|
ep, gerr := h.db.GetPodcastEpisode(actx, episodeID)
|
||||||
|
if gerr != nil || ep.SourceFeedID != feedID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)",
|
||||||
|
deleted, len(rawIDs), feedID, user.Username)
|
||||||
|
if isHXRequest(c) {
|
||||||
|
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
// flipSubscriptionToAll implements issue #3: Download All (button or
|
// flipSubscriptionToAll implements issue #3: Download All (button or
|
||||||
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
|
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
|
||||||
// per-user, never global (files are shared D6, policies are not). The
|
// per-user, never global (files are shared D6, policies are not). The
|
||||||
@@ -74,6 +191,7 @@ func (h *PodcastHandler) flipSubscriptionToAll(ctx context.Context, userID, feed
|
|||||||
AutoDownload: "all",
|
AutoDownload: "all",
|
||||||
AutoDownloadCount: pgtype.Int4{}, // all needs no count
|
AutoDownloadCount: pgtype.Int4{}, // all needs no count
|
||||||
})
|
})
|
||||||
|
log.Printf("[PODCAST] flip: user %s → policy 'all' for feed %s (Download All / add checkbox)", userID, feedID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler {
|
func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler {
|
||||||
@@ -85,7 +203,33 @@ func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worke
|
|||||||
type AddPodcastFeedRequest struct {
|
type AddPodcastFeedRequest struct {
|
||||||
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
|
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
|
||||||
URL string `json:"url" form:"url" validate:"required"`
|
URL string `json:"url" form:"url" validate:"required"`
|
||||||
DownloadAll bool `json:"download_all" form:"download_all"`
|
DownloadAll FlexBool `json:"download_all" form:"download_all"`
|
||||||
|
// DownloadOrder: the initial listening order for THIS feed, applied
|
||||||
|
// only with DownloadAll (§0 item 5c) — it both seeds the queue in
|
||||||
|
// listening order and sets the caller's order override. ""/"feed" =
|
||||||
|
// no override row.
|
||||||
|
DownloadOrder string `json:"download_order,omitempty" form:"download_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexBool binds checkbox-style booleans on every wire. echo bool-binds
|
||||||
|
// via strconv.ParseBool, which accepts "true"/"1" but REJECTS the
|
||||||
|
// browser-native checkbox value "on" — and a bind failure is fatal for
|
||||||
|
// the WHOLE request (the add modal would 400 "invalid request" on any
|
||||||
|
// form variant that posts "on"). §0 item 2 (2026-10-30).
|
||||||
|
//
|
||||||
|
// A checkbox only contributes its field when CHECKED, so for form
|
||||||
|
// posts presence really is the signal — JSON's native bool binding
|
||||||
|
// ("false"→false) is unaffected. Absence on either wire = false.
|
||||||
|
type FlexBool bool
|
||||||
|
|
||||||
|
func (b *FlexBool) UnmarshalParam(v string) error {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "false", "0", "off", "n", "no":
|
||||||
|
*b = false
|
||||||
|
default:
|
||||||
|
*b = true // "true", "on", "yes", "checked", "1" — or any truthy variant
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePodcastSubscriptionRequest struct {
|
type UpdatePodcastSubscriptionRequest struct {
|
||||||
@@ -157,7 +301,23 @@ func (h *PodcastHandler) AddFeed(c *echo.Context) error {
|
|||||||
return podcastErrorResponse(c, err)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if req.DownloadAll && res.Feed != nil {
|
if req.DownloadAll && res.Feed != nil {
|
||||||
if _, err := h.worker.QueueFeedDownloads(c.Request().Context(), res.Feed.ID); err != nil {
|
// §0 item 5c: the add modal's order dropdown is the feed's
|
||||||
|
// INITIAL listening order — it IS the download order (the queue
|
||||||
|
// seeds in that order below). 'feed'/'native' = no override row.
|
||||||
|
order := "native"
|
||||||
|
switch req.DownloadOrder {
|
||||||
|
case "reverse", "oldest", "newest":
|
||||||
|
order = req.DownloadOrder
|
||||||
|
payload, _ := json.Marshal(map[string]string{"order": order})
|
||||||
|
if err := h.db.UpsertUserSettingByKey(c.Request().Context(), database.UpsertUserSettingByKeyParams{
|
||||||
|
UserID: user.ID, SettingKey: podcastOrderSettingKey(res.Feed.ID.String()), SettingValue: payload,
|
||||||
|
}); err != nil {
|
||||||
|
// subscription + queue stand; the dropdown just won't
|
||||||
|
// preselect it (surfaced on the next order set).
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := h.worker.QueueFeedDownloadsAs(c.Request().Context(), user.ID, res.Feed.ID); err != nil {
|
||||||
// subscription stands; download-all failure surfaces via the
|
// subscription stands; download-all failure surfaces via the
|
||||||
// episode list states.
|
// episode list states.
|
||||||
}
|
}
|
||||||
@@ -305,7 +465,7 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
|
|||||||
if isHXRequest(c) {
|
if isHXRequest(c) {
|
||||||
// Policy changes feed the auto-download union on the next poll.
|
// Policy changes feed the auto-download union on the next poll.
|
||||||
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
||||||
return h.hxEpisodeList(c, feedID.String(), true)
|
return h.hxEpisodeList(c, feedID.String(), true, false)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, updated)
|
return c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
@@ -344,7 +504,7 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
|
|||||||
return podcastErrorResponse(c, err)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
if isHXRequest(c) {
|
||||||
return h.hxEpisodeList(c, feedID.String(), false)
|
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
|
||||||
}
|
}
|
||||||
@@ -387,7 +547,8 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
|||||||
eidRaw := c.Param("eid")
|
eidRaw := c.Param("eid")
|
||||||
if eidRaw == "all" {
|
if eidRaw == "all" {
|
||||||
// Download All affordance: exactly the non-downloaded set (pin).
|
// Download All affordance: exactly the non-downloaded set (pin).
|
||||||
queued, err := h.worker.QueueFeedDownloads(ctx, feedID)
|
// Queues in the CALLER's listening order (§0 item 5b).
|
||||||
|
queued, err := h.worker.QueueFeedDownloadsAs(ctx, user.ID, feedID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return podcastErrorResponse(c, err)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
@@ -395,7 +556,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
|||||||
// Download All flips the caller's policy to all (issue #3)
|
// Download All flips the caller's policy to all (issue #3)
|
||||||
// — the oob select keeps the dropdown honest.
|
// — the oob select keeps the dropdown honest.
|
||||||
h.flipSubscriptionToAll(ctx, user.ID, feedID)
|
h.flipSubscriptionToAll(ctx, user.ID, feedID)
|
||||||
return h.hxEpisodeList(c, feedID.String(), true)
|
return h.hxEpisodeList(c, feedID.String(), true, false)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
|
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
|
||||||
}
|
}
|
||||||
@@ -407,7 +568,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
|||||||
return podcastErrorResponse(c, err)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
if isHXRequest(c) {
|
||||||
return h.hxEpisodeList(c, feedID.String(), false)
|
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
|
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
|
||||||
}
|
}
|
||||||
@@ -480,9 +641,10 @@ func podcastOrderSettingKey(feedID string) string {
|
|||||||
return "podcast_order:" + feedID
|
return "podcast_order:" + feedID
|
||||||
}
|
}
|
||||||
|
|
||||||
// podcastOrderValid: native (feed position) | oldest | newest.
|
// podcastOrderValid native (feed position) | reverse (position DESC,
|
||||||
|
// no dates) | oldest | newest.
|
||||||
func podcastOrderValid(o string) bool {
|
func podcastOrderValid(o string) bool {
|
||||||
return o == "native" || o == "oldest" || o == "newest"
|
return services.ValidPodcastEpisodeOrder(o)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFeedOrder reads the caller's effective order for a feed
|
// GetFeedOrder reads the caller's effective order for a feed
|
||||||
@@ -515,7 +677,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Order string `json:"order" form:"order" validate:"required,oneof=native oldest newest"`
|
Order string `json:"order" form:"order" validate:"required,oneof=native reverse oldest newest"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
@@ -541,7 +703,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
if isHXRequest(c) {
|
||||||
return h.hxEpisodeList(c, feedID.String(), false)
|
return h.hxEpisodeList(c, feedID.String(), false, true)
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
|
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
|
||||||
}
|
}
|
||||||
@@ -567,19 +729,11 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
|
|||||||
for i := range eps {
|
for i := range eps {
|
||||||
views = append(views, episodeView(&eps[i]))
|
views = append(views, episodeView(&eps[i]))
|
||||||
}
|
}
|
||||||
sortSlice(views, func(a, b PodcastEpisodeView) bool {
|
// Effective listening order (order consolidation §0 item 5a).
|
||||||
switch order {
|
services.OrderEpisodes(views, order,
|
||||||
case "oldest":
|
func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
|
||||||
return episodeSortKey(a) < episodeSortKey(b)
|
func(v PodcastEpisodeView) string { return v.Date },
|
||||||
case "newest":
|
)
|
||||||
return episodeSortKey(a) > episodeSortKey(b)
|
|
||||||
default: // native: feed position (fetch-maintained)
|
|
||||||
if a.FeedPosition != b.FeedPosition {
|
|
||||||
return a.FeedPosition < b.FeedPosition
|
|
||||||
}
|
|
||||||
return a.Date < b.Date
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
sequence := make([]PodcastEpisodeView, 0, len(views))
|
sequence := make([]PodcastEpisodeView, 0, len(views))
|
||||||
gaps := make([]PodcastEpisodeView, 0)
|
gaps := make([]PodcastEpisodeView, 0)
|
||||||
@@ -598,12 +752,6 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// episodeSortKey: chronological key for oldest/newest (date, then
|
|
||||||
// native position as the same-day tiebreaker).
|
|
||||||
func episodeSortKey(v PodcastEpisodeView) string {
|
|
||||||
return v.Date + fmt.Sprintf("%06d", v.FeedPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortSlice: sort.Slice with a controller-friendly wrapper.
|
// sortSlice: sort.Slice with a controller-friendly wrapper.
|
||||||
func sortSlice[T any](s []T, less func(a, b T) bool) {
|
func sortSlice[T any](s []T, less func(a, b T) bool) {
|
||||||
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
|
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
|
||||||
|
|||||||
@@ -178,6 +178,21 @@ func validateSettingValue(def database.SettingDefault, value string) error {
|
|||||||
if _, err := strconv.ParseBool(value); err != nil {
|
if _, err := strconv.ParseBool(value); err != nil {
|
||||||
return fmt.Errorf("value must be true or false")
|
return fmt.Errorf("value must be true or false")
|
||||||
}
|
}
|
||||||
|
case database.SettingTypeFloat:
|
||||||
|
f, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("value must be a number")
|
||||||
|
}
|
||||||
|
if def.Min != "" {
|
||||||
|
if mn, err := strconv.ParseFloat(def.Min, 64); err == nil && f < mn {
|
||||||
|
return fmt.Errorf("value must be >= %s", def.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
if mx, err := strconv.ParseFloat(def.Max, 64); err == nil && f > mx {
|
||||||
|
return fmt.Errorf("value must be <= %s", def.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
case database.SettingTypeString:
|
case database.SettingTypeString:
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return fmt.Errorf("value must not be empty")
|
return fmt.Errorf("value must not be empty")
|
||||||
|
|||||||
@@ -41,10 +41,13 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
|||||||
// the component and asserts the hash matches.
|
// the component and asserts the hash matches.
|
||||||
//
|
//
|
||||||
// style-src keeps 'unsafe-inline' for htmx-injected styles and
|
// style-src keeps 'unsafe-inline' for htmx-injected styles and
|
||||||
// Alpine :style attribute writes.
|
// Alpine :style attribute writes, plus blob: for the
|
||||||
|
// reader-generated stylesheets foliate injects at open time
|
||||||
|
// (blocked since the CSP migration — every /readers load
|
||||||
|
// logged a violation; selection/highlight styling rode on it).
|
||||||
// font/img blob: — the reader renders EPUB-embedded fonts and
|
// font/img blob: — the reader renders EPUB-embedded fonts and
|
||||||
// images from blob: URLs inside the foliate content frame.
|
// images from blob: URLs inside the foliate content frame.
|
||||||
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
|
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
|
||||||
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||||
|
|
||||||
|
|||||||
@@ -17,17 +17,16 @@ func registerCollectionsRoutes(cfg *Config) {
|
|||||||
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
||||||
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
||||||
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
||||||
|
// The chain's listening order lives ON the collection (plan §10 R2):
|
||||||
|
// merges into view_settings; the episode chain producer honors it.
|
||||||
|
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
|
||||||
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
||||||
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
||||||
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
||||||
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
|
|
||||||
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
||||||
|
|
||||||
// Smart collection rule CRUD (the rules builder persists through
|
// Smart collection rule CRUD (the rules builder persists through
|
||||||
// these; type follows rules presence — first rule = smart).
|
// these; type follows rules presence — first rule = smart).
|
||||||
collections.POST("/:id/rules", cfg.CollectionHandler.AddRule)
|
|
||||||
collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule)
|
|
||||||
collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule)
|
|
||||||
|
|
||||||
// Device shelf mapping routes
|
// Device shelf mapping routes
|
||||||
deviceCollections := protected.Group("/devices/:id/collections")
|
deviceCollections := protected.Group("/devices/:id/collections")
|
||||||
|
|||||||
+107
-8
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/rls"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
@@ -408,9 +409,19 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSR id-label resolution for a URL-carried qf tree: the drawer's
|
||||||
|
// value cells show NAMES from first paint instead of raw uuids
|
||||||
|
// (2026-11-01 — same story as the collection editor above).
|
||||||
|
labelsJSON := "{}"
|
||||||
|
if qfRaw := c.QueryParam("qf"); qfRaw != "" {
|
||||||
|
if t, perr := services.ParseFilterTree(qfRaw); perr == nil {
|
||||||
|
labelsJSON = resolveTreeLabelsJSON(c, cfg, user.ID, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
||||||
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount, labelsJSON).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -561,13 +572,17 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
colData := make([]templates.CollectionData, len(collections))
|
colData := make([]templates.CollectionData, len(collections))
|
||||||
colService := cfg.CollectionHandler.CollectionService()
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
for i, col := range collections {
|
for i, col := range collections {
|
||||||
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
|
isSmart := services.IsCollectionSmart(col)
|
||||||
bookCount := 0
|
bookCount := 0
|
||||||
if isSmart {
|
if isSmart {
|
||||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
|
||||||
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
|
col, services.SmartEvalOptions{
|
||||||
LibraryID: libScope,
|
LibraryID: libScope,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: func() pgtype.UUID {
|
||||||
|
uid, _ := uuid.Parse(user.ID)
|
||||||
|
return uuidToPGType(uid)
|
||||||
|
}(),
|
||||||
})
|
})
|
||||||
if evalErr == nil {
|
if evalErr == nil {
|
||||||
bookCount = len(matches)
|
bookCount = len(matches)
|
||||||
@@ -695,6 +710,35 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
|
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The editor's vocabulary + prefill (item 3.4): the unified
|
||||||
|
// builder in the collection's scope. A stored tree renders
|
||||||
|
// as-is. (The legacy rules-array preload retired 2026-10-31
|
||||||
|
// with the rules-array storage — the tree is the only form.)
|
||||||
|
scope := "books"
|
||||||
|
treeJSON := "{}"
|
||||||
|
var parsedTree *services.FilterTree
|
||||||
|
if len(collection.FilterTree) > 0 {
|
||||||
|
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
|
||||||
|
scope = t.FilterScope()
|
||||||
|
if b, merr := json.Marshal(t); merr == nil {
|
||||||
|
treeJSON = string(b)
|
||||||
|
}
|
||||||
|
parsedTree = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
|
||||||
|
if merr != nil {
|
||||||
|
fieldsPayload = []byte("[]")
|
||||||
|
}
|
||||||
|
fieldsJSON := string(fieldsPayload)
|
||||||
|
|
||||||
|
// SSR id-label resolution: the stored tree arrives as bare ids;
|
||||||
|
// embed the id→name map so value cells render NAMES at first
|
||||||
|
// paint (2026-11-01: the client's pick-time cache dies with the
|
||||||
|
// page, and the quiet suggest fetch can't label ids whose media
|
||||||
|
// doesn't match the tree's sibling rules — raw uuids on refresh).
|
||||||
|
labelsJSON := resolveTreeLabelsJSON(c, cfg, user.ID, parsedTree)
|
||||||
|
|
||||||
colData := templates.CollectionData{
|
colData := templates.CollectionData{
|
||||||
ID: collectionID,
|
ID: collectionID,
|
||||||
Name: collection.Name,
|
Name: collection.Name,
|
||||||
@@ -702,11 +746,11 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
Color: utils.PgTextString(collection.Color),
|
Color: utils.PgTextString(collection.Color),
|
||||||
Icon: utils.PgTextString(collection.Icon),
|
Icon: utils.PgTextString(collection.Icon),
|
||||||
IsSystem: collection.IsSystemCollection.Bool,
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
|
IsSmart: services.IsCollectionSmart(collection),
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
|
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope, labelsJSON).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1507,8 +1551,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
@@ -1562,6 +1605,62 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Admin all-podcasts view (§0 item 8): every feed from every user with
|
||||||
|
// maintenance aggregates (disk usage first). The aggregate read runs
|
||||||
|
// under the FIRST-ADMIN identity — the RLS admin escape (invariant
|
||||||
|
// 12, poller/fetcher contract) — so admins see feeds from libraries
|
||||||
|
// hidden even from them.
|
||||||
|
frontendProtected.GET("/admin/podcasts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUUID, err := cfg.Queries.GetFirstAdmin(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||||
|
}
|
||||||
|
// cfg.Queries is ScopedDB-backed: the context identity is all the
|
||||||
|
// aggregate read needs.
|
||||||
|
actx := rls.WithUser(c.Request().Context(), adminUUID)
|
||||||
|
|
||||||
|
rows, err := cfg.Queries.ListAllPodcastFeedsAdmin(actx)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds := make([]templates.AdminPodcastFeedData, 0, len(rows))
|
||||||
|
var totalBytes int64
|
||||||
|
totalDownloaded, totalEpisodes := 0, 0
|
||||||
|
for _, r := range rows {
|
||||||
|
lastFetch := "never"
|
||||||
|
if r.LastFetchedAt.Valid {
|
||||||
|
lastFetch = r.LastFetchedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006 15:04")
|
||||||
|
}
|
||||||
|
feeds = append(feeds, templates.AdminPodcastFeedData{
|
||||||
|
ID: uuid.UUID(r.ID.Bytes).String(),
|
||||||
|
Title: r.Title,
|
||||||
|
Host: r.Host.String,
|
||||||
|
LibraryName: r.LibraryName,
|
||||||
|
Subscribers: int(r.SubscriberCount),
|
||||||
|
SubscriberNames: r.SubscriberNames,
|
||||||
|
EpisodeTotal: int(r.EpisodeTotal),
|
||||||
|
EpisodeDownloaded: int(r.EpisodeDownloaded),
|
||||||
|
DiskBytes: r.DiskBytes,
|
||||||
|
LastFetched: lastFetch,
|
||||||
|
})
|
||||||
|
totalBytes += r.DiskBytes
|
||||||
|
totalEpisodes += int(r.EpisodeTotal)
|
||||||
|
totalDownloaded += int(r.EpisodeDownloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.AdminPodcasts(user, feeds, len(feeds), totalDownloaded, totalEpisodes, totalBytes).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}))
|
||||||
|
|
||||||
// Admin users page
|
// Admin users page
|
||||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -75,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
LastName: utils.PgTextString(userDB.LastName),
|
LastName: utils.PgTextString(userDB.LastName),
|
||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt.Time,
|
||||||
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||||
|
AudioResumeRewind: int(userDB.AudioResumeRewindSeconds),
|
||||||
KeepScreenAwake: userDB.KeepScreenAwake,
|
KeepScreenAwake: userDB.KeepScreenAwake,
|
||||||
ShowPodcastsNav: showPodcasts,
|
ShowPodcastsNav: showPodcasts,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -270,6 +272,15 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
|
|||||||
// collectionDataFromRow builds the template collection payload shared by
|
// collectionDataFromRow builds the template collection payload shared by
|
||||||
// the collection detail page and its books-grid partial.
|
// the collection detail page and its books-grid partial.
|
||||||
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
|
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
|
||||||
|
chainOrder := ""
|
||||||
|
if len(collection.ViewSettings) > 0 {
|
||||||
|
var vs map[string]interface{}
|
||||||
|
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||||
|
if s, ok := vs["chain_order"].(string); ok {
|
||||||
|
chainOrder = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return templates.CollectionData{
|
return templates.CollectionData{
|
||||||
ID: collectionID,
|
ID: collectionID,
|
||||||
Name: collection.Name,
|
Name: collection.Name,
|
||||||
@@ -278,6 +289,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
|||||||
Icon: collection.Icon.String,
|
Icon: collection.Icon.String,
|
||||||
IsSystem: collection.IsSystemCollection.Bool,
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
IsSmart: isSmart,
|
IsSmart: isSmart,
|
||||||
|
ChainOrder: chainOrder,
|
||||||
BookCount: bookCount,
|
BookCount: bookCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,25 +298,72 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
|||||||
// detail page and the books-grid partial: smart evaluation, a dashboard
|
// detail page and the books-grid partial: smart evaluation, a dashboard
|
||||||
// query section, or manual membership. Callers resolve the scope:
|
// query section, or manual membership. Callers resolve the scope:
|
||||||
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
||||||
|
// resolveTreeLabelsJSON — SSR id-label resolution for a page-rendered
|
||||||
|
// filter tree: walks the tree's id-kind rules, resolves uuids to display
|
||||||
|
// names through the search engine (RLS/visibility ride the request
|
||||||
|
// identity), and returns the JSON map embedded into the builder mount.
|
||||||
|
// Best-effort: any failure yields "{}" and the page renders with the
|
||||||
|
// client's fallbacks intact (2026-11-01, filter_labels.go for the WHY).
|
||||||
|
func resolveTreeLabelsJSON(c *echo.Context, cfg *Config, userID string, tree *services.FilterTree) string {
|
||||||
|
if tree == nil || len(tree.Children) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
labels := cfg.MediaHandler.SearchService().FilterTreeLabels(
|
||||||
|
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, tree)
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
b, merr := json.Marshal(labels)
|
||||||
|
if merr != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
||||||
colService := cfg.CollectionHandler.CollectionService()
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
|
isSmart = services.IsCollectionSmart(collection)
|
||||||
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
||||||
|
|
||||||
if isSmart {
|
if isSmart {
|
||||||
// Smart (dynamic) collection: membership is evaluated live —
|
// Smart (dynamic) collection: membership is evaluated live —
|
||||||
// visible libraries, live items only, any enabled rule matches.
|
// visible libraries, live items only, rules applied under the
|
||||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
// collection's match mode (item 2.4: any|all), with the
|
||||||
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
// viewer's progress feeding played/unplayed rules.
|
||||||
|
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
|
||||||
|
collection, services.SmartEvalOptions{
|
||||||
LibraryID: libScope,
|
LibraryID: libScope,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
|
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if evalErr != nil {
|
if evalErr != nil {
|
||||||
return nil, isSmart, "books_load_error"
|
return nil, isSmart, "books_load_error"
|
||||||
}
|
}
|
||||||
|
// The visible order IS the play order: the curated listening
|
||||||
|
// order (view_settings.chain_order, §10 R2) re-orders the
|
||||||
|
// episode cards exactly as the sequence producer will play
|
||||||
|
// them, so the select's effect is visible and Play launches
|
||||||
|
// from the card the user sees first (2026-11-01 user report:
|
||||||
|
// the select changed nothing on the page).
|
||||||
|
services.OrderEpisodeMedia(matches, handlers.CollectionChainOrder(collection.ViewSettings))
|
||||||
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
||||||
|
// Episode-awareness for smart matches (item 2.5): episode cards
|
||||||
|
// carry the play affordance (chain via ?queue=collection:).
|
||||||
|
// BookCardsFromMediaItems preserves match order, so a parallel
|
||||||
|
// walk is enough; feed titles stay empty here (batch lookup is
|
||||||
|
// polish, not acceptance).
|
||||||
|
for i := range books {
|
||||||
|
if i < len(matches) && matches[i].Source == "podcast_episode" {
|
||||||
|
books[i].IsEpisode = true
|
||||||
|
books[i].DownloadState = matches[i].DownloadState
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
dashboardSvc := services.NewDashboardService(cfg.Queries, cfg.CollectionHandler.CollectionService().Search())
|
||||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||||
if secErr != nil {
|
if secErr != nil {
|
||||||
return nil, isSmart, "books_load_error"
|
return nil, isSmart, "books_load_error"
|
||||||
@@ -331,11 +390,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
|
|||||||
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
|
// Episode collections (plan item 2.3): the detailed query
|
||||||
|
// keeps RLS-invisible rows as Hidden cards (unsubscribed
|
||||||
|
// episodes render an "unavailable" hint) and tags episode
|
||||||
|
// rows with feed title + download state. Same position
|
||||||
|
// ordering; the legacy INNER-JOIN shape remains on the
|
||||||
|
// library-scoped branch above.
|
||||||
|
collItems, collErr := cfg.Queries.GetCollectionItemsDetailed(c.Request().Context(), collUUID)
|
||||||
if collErr != nil {
|
if collErr != nil {
|
||||||
collItems = nil
|
collItems = nil
|
||||||
}
|
}
|
||||||
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
|
books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return books, isSmart, ""
|
return books, isSmart, ""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
|
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -20,7 +21,7 @@ func registerPodcastRoutes(cfg *Config) {
|
|||||||
// refreshed partial directly — no event indirection to miss. The
|
// refreshed partial directly — no event indirection to miss. The
|
||||||
// view assembly lives here (templates) while the action logic lives
|
// view assembly lives here (templates) while the action logic lives
|
||||||
// in the handler; this closure is the sanctioned seam.
|
// in the handler; this closure is the sanctioned seam.
|
||||||
cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect bool) error {
|
cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -48,6 +49,14 @@ func registerPodcastRoutes(cfg *Config) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if includeOrderSelect {
|
||||||
|
// §0 item 3: out-of-band swap of the order select — the
|
||||||
|
// "Reverse feed order" button flips to reverse; the
|
||||||
|
// dropdown mirrors the server state immediately.
|
||||||
|
if err := templates.PodcastOrderSelectOOB(data.Feed.ID, data.Order).Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,4 +74,10 @@ func registerPodcastRoutes(cfg *Config) {
|
|||||||
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
|
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
|
||||||
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
|
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
|
||||||
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
|
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
|
||||||
|
|
||||||
|
// Admin maintenance (§0 item 8): file deletion frees the disk and
|
||||||
|
// resets rows to pending. Handler checks admin role.
|
||||||
|
feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles))
|
||||||
|
feeds.POST("/episodes/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFilesBulk))
|
||||||
|
feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
|
||||||
}
|
}
|
||||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user