feat(books): JSON content-negotiation for GET /books/bulk/step-edit (bookshelf-7hc1) #636

Merged
zombor merged 2 commits from bd-bookshelf-7hc1 into main 2026-06-19 13:04:45 +00:00
Owner

Summary

Adds WantsJSON content-negotiation to GET /books/bulk/step-edit, the only remaining HTML-only page handler that should also serve JSON. Returns StepEditResponse{book, step_nav} for Accept: application/json requests; HTML behaviour is completely unchanged.

Route Audit Inventory (full scope of bookshelf-7hc1)

Already content-negotiates (HTML+JSON) — no change needed

  • GET / — home
  • GET /books, GET /books/{id} — books list/detail
  • GET /authors, GET /authors/{id} — authors
  • GET /libraries, GET /libraries/{id}, etc. — libraries
  • GET /shelves, GET /shelves/{id} — static shelves
  • GET /magic-shelves/{id} — magic shelves
  • GET /series, GET /series/{name} — series
  • GET /stats, GET /notebook, GET /admin/audit-log — stats/notebook/audit
  • GET /settings/* — all settings pages
  • GET /bookdrop — bookdrop proposals list
  • GET /admin/workflows, GET /admin/workflows/{instanceID} — workflow admin (fixed in #614)
  • GET /account, GET /admin/users, GET /admin/users/new, GET /admin/users/{id}/edit — users

JSON-only (correct, no page template) — no change needed

  • GET /reading-sessions — reading sessions API
  • GET /books/{id}/annotations — annotations API
  • GET /books/{id}/bookmarks — bookmarks API
  • GET /books/{id}/notes — notes API
  • GET /books/{id}/file/{fileID}/progress — progress API
  • GET /books/{id}/metadata/providers, /candidates, /llm-fetch/{job_id} — metadata APIs
  • GET /libraries/{id}/scan-status — scan status polling
  • GET /admin/wf-status/{instanceID} — workflow status polling

Binary/protocol routes — intentionally not JSON

  • GET /books/{id}/file/{fileID} — file download (Content-Disposition: attachment)
  • GET /books/{id}/file/{fileID}/content, /pages/{n}, /audio — reader streams
  • GET /data/images/{id}/cover.jpg, thumbnail.jpg — cover images
  • GET /authors/{id}/photo — author photo
  • GET /auth/oidc/login, GET /auth/oidc/callback — OIDC auth flow redirects

HTML-only, intentional — documented, no code change

  • GET /login — login form (POST /login already handles JSON for API clients; GET is the browser form only)
  • GET /books/{id}/read — reader iframe shell. This is intentionally HTML-only: the reader JS fetches underlying data (EPUB file, progress, bookmarks) via separate JSON sub-endpoints. There is no meaningful JSON payload for "open reader shell".

HTML-only, fixed in this PR

  • GET /books/bulk/step-edit this PR adds WantsJSON → StepEditResponse{book, step_nav}

Test plan

  • StepEditHandler — JSON context: status 200, Content-Type: application/json, book title, step_nav.human_position, step_nav.total_count
  • HTML branch untouched — existing tests still pass
  • step_edit_handler.go coverage: 100%
  • make coverage passes
  • make build clean

Closes bead bookshelf-7hc1 on merge.

## Summary Adds `WantsJSON` content-negotiation to `GET /books/bulk/step-edit`, the only remaining HTML-only page handler that should also serve JSON. Returns `StepEditResponse{book, step_nav}` for `Accept: application/json` requests; HTML behaviour is completely unchanged. ## Route Audit Inventory (full scope of bookshelf-7hc1) ### Already content-negotiates (HTML+JSON) — no change needed - `GET /` — home - `GET /books`, `GET /books/{id}` — books list/detail - `GET /authors`, `GET /authors/{id}` — authors - `GET /libraries`, `GET /libraries/{id}`, etc. — libraries - `GET /shelves`, `GET /shelves/{id}` — static shelves - `GET /magic-shelves/{id}` — magic shelves - `GET /series`, `GET /series/{name}` — series - `GET /stats`, `GET /notebook`, `GET /admin/audit-log` — stats/notebook/audit - `GET /settings/*` — all settings pages - `GET /bookdrop` — bookdrop proposals list - `GET /admin/workflows`, `GET /admin/workflows/{instanceID}` — workflow admin (fixed in #614) - `GET /account`, `GET /admin/users`, `GET /admin/users/new`, `GET /admin/users/{id}/edit` — users ### JSON-only (correct, no page template) — no change needed - `GET /reading-sessions` — reading sessions API - `GET /books/{id}/annotations` — annotations API - `GET /books/{id}/bookmarks` — bookmarks API - `GET /books/{id}/notes` — notes API - `GET /books/{id}/file/{fileID}/progress` — progress API - `GET /books/{id}/metadata/providers`, `/candidates`, `/llm-fetch/{job_id}` — metadata APIs - `GET /libraries/{id}/scan-status` — scan status polling - `GET /admin/wf-status/{instanceID}` — workflow status polling ### Binary/protocol routes — intentionally not JSON - `GET /books/{id}/file/{fileID}` — file download (Content-Disposition: attachment) - `GET /books/{id}/file/{fileID}/content`, `/pages/{n}`, `/audio` — reader streams - `GET /data/images/{id}/cover.jpg`, `thumbnail.jpg` — cover images - `GET /authors/{id}/photo` — author photo - `GET /auth/oidc/login`, `GET /auth/oidc/callback` — OIDC auth flow redirects ### HTML-only, intentional — documented, no code change - `GET /login` — login form (POST /login already handles JSON for API clients; GET is the browser form only) - `GET /books/{id}/read` — reader iframe shell. This is intentionally HTML-only: the reader JS fetches underlying data (EPUB file, progress, bookmarks) via separate JSON sub-endpoints. There is no meaningful JSON payload for "open reader shell". ### HTML-only, fixed in this PR - **`GET /books/bulk/step-edit` — ✅ this PR adds WantsJSON → StepEditResponse{book, step_nav}** ## Test plan - [x] `StepEditHandler` — JSON context: status 200, `Content-Type: application/json`, book title, `step_nav.human_position`, `step_nav.total_count` - [x] HTML branch untouched — existing tests still pass - [x] `step_edit_handler.go` coverage: 100% - [x] `make coverage` passes - [x] `make build` clean Closes bead bookshelf-7hc1 on merge.
feat(books): add JSON content-negotiation to GET /books/bulk/step-edit (bookshelf-7hc1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 2m19s
/ Lint (pull_request) Successful in 4m39s
/ E2E API (pull_request) Successful in 4m40s
/ E2E Browser (pull_request) Successful in 4m41s
/ Test (pull_request) Successful in 5m23s
/ Integration (pull_request) Successful in 5m24s
6417c76a05
Add WantsJSON branch to StepEditHandler so the multi-book metadata editor
endpoint serves application/json when the client sends Accept: application/json,
returning StepEditResponse{Book, StepNav}. HTML behaviour is unchanged.

Tests: unit tests assert status 200, Content-Type, book title, and step-nav fields
for both HTML and JSON branches. step_edit_handler.go maintains 100% coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

CODE REVIEW (RE-REVIEW): APPROVED

Re-review of #567 (bd-bookshelf-c15a.2) against prior findings in comment 6689.

Prior Finding Verification

MAJOR 1: ExternalID OR-dup loss — RESOLVED

The fix rewrites FindDuplicatesByExternalID using two independent EQUI-JOIN subqueries (hardcover and goodreads) merged with UNION ALL (store.go:278-381). Each provider query uses an equi-join with no OR. A book with both hardcover_id and goodreads_id appears exactly once per provider group.

The service.go seen-map key is now a (bookID, groupKey) composite (service.go:92) — so book A can appear in both hc:HC001 and gr:GR001 groups without being dropped. The e2e test seeds exactly the A+B (hc) / A+C (gr) scenario and asserts HaveLen(2). Finding is resolved.

MAJOR 2: Directory/Filename correlated-subquery — RESOLVED

Both FindDuplicatesByDirectory and FindDuplicatesByFilename now use derived-table EQUI-JOINs with no per-row correlated subquery (store.go:453-493 for directory, store.go:551-595 for filename). Migration 0037 adds idx_book_file_dedup_dir (book_id, is_book, file_sub_path) on book_file. All ORDER BY clauses use a two-column total order with unique tiebreaker. Finding is resolved.

MAJOR 3: Positive e2e tests for same_directory, filename, both-ids external_id — RESOLVED

  • seedDirectoryDuplicateLibrary() + same_directory match Describe (e2e/api/dedup_test.go:555-618): seeds two books sharing a directory, asserts group with match_type same_directory.
  • seedFilenameDuplicateLibrary() + filename match (e2e/api/dedup_test.go:619-686): seeds two books with same basename in different directories.
  • external_id both-ids book keeps both groups (e2e/api/dedup_test.go:688-822): asserts two distinct provider groups.

All three are positive tests. Finding is resolved.

MINOR: Browser checkbox MustEval bool read — RESOLVED

e2e/browser/find_duplicates_test.go now uses titleAuthorCB.MustEval("() => this.checked").Bool(). Finding is resolved.

MINOR: Stray double blank line — RESOLVED

The extra blank line after the import block is gone. Finding is resolved.

Fresh-Eyes Pass

Multi-user/library scoping: Both new directory and filename finders filter by userLibraryIDs in the inner grp subquery AND the outer WHERE clause. Empty-slice fail-closed guard present. Correct.

Scale: No N+1. All new queries use derived-table joins. ORDER BY uses total-order two-column keys. LIMIT bound on all queries. Covering index on book_file (migration 0037). Clean.

Grimmory compat: Migrations 0036 and 0037 add indexes only, no new columns. Correct.

CSP: No inline style= in templates. CSS classes used throughout. Clean.

[MINOR] e2e/browser/find_duplicates_test.go:494 — single It block contains multiple Expect assertions
The browser spec chains four independent assertions (balanced chip active, strict chip active, titleAuthor unchecked, badge text). Project convention requires one Expect per It. Split into four It blocks. Does not block merge.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

CODE REVIEW (RE-REVIEW): APPROVED Re-review of #567 (bd-bookshelf-c15a.2) against prior findings in comment 6689. ## Prior Finding Verification ### MAJOR 1: ExternalID OR-dup loss — RESOLVED The fix rewrites FindDuplicatesByExternalID using two independent EQUI-JOIN subqueries (hardcover and goodreads) merged with UNION ALL (store.go:278-381). Each provider query uses an equi-join with no OR. A book with both hardcover_id and goodreads_id appears exactly once per provider group. The service.go seen-map key is now a (bookID, groupKey) composite (service.go:92) — so book A can appear in both hc:HC001 and gr:GR001 groups without being dropped. The e2e test seeds exactly the A+B (hc) / A+C (gr) scenario and asserts HaveLen(2). Finding is resolved. ### MAJOR 2: Directory/Filename correlated-subquery — RESOLVED Both FindDuplicatesByDirectory and FindDuplicatesByFilename now use derived-table EQUI-JOINs with no per-row correlated subquery (store.go:453-493 for directory, store.go:551-595 for filename). Migration 0037 adds idx_book_file_dedup_dir (book_id, is_book, file_sub_path) on book_file. All ORDER BY clauses use a two-column total order with unique tiebreaker. Finding is resolved. ### MAJOR 3: Positive e2e tests for same_directory, filename, both-ids external_id — RESOLVED - seedDirectoryDuplicateLibrary() + same_directory match Describe (e2e/api/dedup_test.go:555-618): seeds two books sharing a directory, asserts group with match_type same_directory. - seedFilenameDuplicateLibrary() + filename match (e2e/api/dedup_test.go:619-686): seeds two books with same basename in different directories. - external_id both-ids book keeps both groups (e2e/api/dedup_test.go:688-822): asserts two distinct provider groups. All three are positive tests. Finding is resolved. ### MINOR: Browser checkbox MustEval bool read — RESOLVED e2e/browser/find_duplicates_test.go now uses titleAuthorCB.MustEval("() => this.checked").Bool(). Finding is resolved. ### MINOR: Stray double blank line — RESOLVED The extra blank line after the import block is gone. Finding is resolved. ## Fresh-Eyes Pass Multi-user/library scoping: Both new directory and filename finders filter by userLibraryIDs in the inner grp subquery AND the outer WHERE clause. Empty-slice fail-closed guard present. Correct. Scale: No N+1. All new queries use derived-table joins. ORDER BY uses total-order two-column keys. LIMIT bound on all queries. Covering index on book_file (migration 0037). Clean. Grimmory compat: Migrations 0036 and 0037 add indexes only, no new columns. Correct. CSP: No inline style= in templates. CSS classes used throughout. Clean. [MINOR] e2e/browser/find_duplicates_test.go:494 — single It block contains multiple Expect assertions The browser spec chains four independent assertions (balanced chip active, strict chip active, titleAuthor unchecked, badge text). Project convention requires one Expect per It. Split into four It blocks. Does not block merge. REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Security Review — PR #636 (bookshelf-7hc1)

Reviewed per .claude/rules/review-standard.md. Scope: content-negotiation JSON branch added to GET /books/bulk/step-edit.


What was examined

  • internal/books/step_edit_handler.go — new WantsJSON branch
  • internal/books/dto.go — new StepEditResponse struct
  • internal/books/books.goBook / BookFile struct field tags
  • internal/books/service.goGet function body (checkAccess + userID scoping)
  • internal/books/routes.go — route registration + gate middleware
  • internal/app/app.go — global middleware chain (AuthMiddleware)
  • internal/users/middleware.goAuthMiddleware behaviour for unauthenticated requests

No findings

Per-user scoping preserved. The JSON branch reuses the exact same code path as the HTML branch: userID = d.UserIDFromRequest(r) (sourced from the JWT session, not from the request), then d.Get(ctx, bookID, userID) which calls checkAccess(ctx, userID, id) — the single source-of-truth library-membership gate. A book outside the requesting user's libraries returns 404 before the JSON branch is reached.

Auth gate preserved. GET /books/bulk/step-edit is not in isExempt; AuthMiddleware (global, wraps all routes) redirects browsers to /login and returns 401 to JSON clients when no valid token is present. The route is not wrapped in an admin-only gate (consistent with the HTML path — this is a per-user library-read surface).

No over-exposure. StepEditResponse contains only Book and StepEditNav. The Book struct is already fully serialised by the existing GET /books/{id} JSON endpoint (ShowResponse{Book: ...}). BookFile.FileSubPath was already exposed there — no new surface. StepEditNav contains only navigation URLs and position counters — no sensitive or internal fields.

Pagination / LIMIT preserved. StepEditHandler fetches a single book at ids[pos]; there is no list query and no unbounded result set. The IDs list is validated against MaxBulkBookIDs before reaching this handler.

No sync-protocol routes touched. Kobo/KOReader/OPDS routes are unmodified.

CSRF / method unchanged. This is a GET; no state mutation, no CSRF concern.


REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Review — PR #636 (bookshelf-7hc1) Reviewed per `.claude/rules/review-standard.md`. Scope: content-negotiation JSON branch added to `GET /books/bulk/step-edit`. --- ### What was examined - `internal/books/step_edit_handler.go` — new `WantsJSON` branch - `internal/books/dto.go` — new `StepEditResponse` struct - `internal/books/books.go` — `Book` / `BookFile` struct field tags - `internal/books/service.go` — `Get` function body (checkAccess + userID scoping) - `internal/books/routes.go` — route registration + gate middleware - `internal/app/app.go` — global middleware chain (`AuthMiddleware`) - `internal/users/middleware.go` — `AuthMiddleware` behaviour for unauthenticated requests --- ### No findings **Per-user scoping preserved.** The JSON branch reuses the exact same code path as the HTML branch: `userID = d.UserIDFromRequest(r)` (sourced from the JWT session, not from the request), then `d.Get(ctx, bookID, userID)` which calls `checkAccess(ctx, userID, id)` — the single source-of-truth library-membership gate. A book outside the requesting user's libraries returns 404 before the JSON branch is reached. **Auth gate preserved.** `GET /books/bulk/step-edit` is not in `isExempt`; `AuthMiddleware` (global, wraps all routes) redirects browsers to `/login` and returns 401 to JSON clients when no valid token is present. The route is not wrapped in an admin-only gate (consistent with the HTML path — this is a per-user library-read surface). **No over-exposure.** `StepEditResponse` contains only `Book` and `StepEditNav`. The `Book` struct is already fully serialised by the existing `GET /books/{id}` JSON endpoint (`ShowResponse{Book: ...}`). `BookFile.FileSubPath` was already exposed there — no new surface. `StepEditNav` contains only navigation URLs and position counters — no sensitive or internal fields. **Pagination / LIMIT preserved.** `StepEditHandler` fetches a single book at `ids[pos]`; there is no list query and no unbounded result set. The IDs list is validated against `MaxBulkBookIDs` before reaching this handler. **No sync-protocol routes touched.** Kobo/KOReader/OPDS routes are unmodified. **CSRF / method unchanged.** This is a GET; no state mutation, no CSRF concern. --- REVIEW VERDICT: 0 blocker, 0 major, 0 minor
fix: add json tags to StepEditNav fields (bookshelf-7hc1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 19s
/ E2E API (pull_request) Successful in 1m47s
/ E2E Browser (pull_request) Successful in 1m51s
/ Lint (pull_request) Successful in 1m59s
/ Integration (pull_request) Successful in 2m57s
/ Test (pull_request) Successful in 3m25s
49ee163094
- Position -> json:"position"
- HumanPosition -> json:"human_position"
- TotalCount -> json:"total_count"
- PrevURL -> json:"prev_url"
- NextURL -> json:"next_url"
- DoneURL -> json:"done_url"

Add test coverage: unmarshaling JSON response into map verifies snake_case
serialization of StepNav object keys (human_position, total_count).
zombor force-pushed bd-bookshelf-7hc1 from 49ee163094
All checks were successful
/ JS Unit Tests (pull_request) Successful in 19s
/ E2E API (pull_request) Successful in 1m47s
/ E2E Browser (pull_request) Successful in 1m51s
/ Lint (pull_request) Successful in 1m59s
/ Integration (pull_request) Successful in 2m57s
/ Test (pull_request) Successful in 3m25s
to 4e654ac798
All checks were successful
/ JS Unit Tests (pull_request) Successful in 22s
/ E2E API (pull_request) Successful in 1m53s
/ Lint (pull_request) Successful in 1m56s
/ Integration (pull_request) Successful in 2m31s
/ Test (pull_request) Successful in 2m39s
/ E2E Browser (pull_request) Successful in 2m44s
2026-06-19 13:01:35 +00:00
Compare
zombor merged commit ba19b04f8b into main 2026-06-19 13:04:45 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!636
No description provided.