Library content kind: decouple comic detection from CBX format [shot:content-kind-screenshot] (bookshelf-qi21k.1) #1421

Merged
zombor merged 2 commits from bd-bookshelf-qi21k.1 into main 2026-08-10 13:42:26 +00:00
Owner

Summary

Adds a per-library Content Kind (Auto/Comics/Magazines/Ebooks/Audiobooks)
so a magazine stored as .cbr stops being treated as a comic, without
touching the reader (Option A, confirmed 2026-08-09 on the parent epic
bookshelf-qi21k).

  • Storage: app_settings keyed by library ID (content_kind.library.<id>)
    — the Grimmory library table gets no new column. Absent key = Auto =
    today's extension-based behavior, zero regression.
  • Resolution: IsComic = hasCBXFile AND kind ∈ {Comics, Auto}.
    • internal/books.Show gets an optional variadic content-kind resolver
      param so the ~30 existing Show(...) test call sites keep compiling
      unchanged; wired for real in books/wire.go.
    • internal/books.WithLibraryContentKind wraps the single-book metadata
      getter used by refetch / interactive candidate search / the step-through
      editor, so a Magazines/Ebooks/Audiobooks library's CBX files skip comic
      provider routing and comic-issue query building.
  • Reader unchanged: a .cbr still opens in the CBX page reader
    regardless of kind — nothing in the reader path was touched.
  • UI: Content Kind <select> on the library create/edit modal
    (internal/library), admin-gated per existing library management
    (ManipulateLibrary).
  • Backfill: migration 0053 sets the user's existing "Magazines"
    library to kind=magazines (data-only, no schema change); every other
    library defaults to Auto.
  • Docs: docs/content/docs/your-library/libraries.md gets a
    Content Kind section.
  • Deferred (v2, per epic): bulk/LLM sweep wiring
    (internal/app/build_extended_deps.go, build_llm_deps.go,
    build_enrich_deps.go) constructs its own separate
    GetMetadataForRefetch and is intentionally untouched here.

Test plan

  • go test ./internal/settings/... — new library_content_kind.go
    logic, 100% coverage
  • go test ./internal/books/...WithLibraryContentKind +
    resolveShowIsComic (Show's HTML and JSON branches), 100% coverage
    on new code, all ~5000 pre-existing specs unaffected
  • go test ./internal/library/... — content-kind select rendering
    (real template), save-on-create/update, nil-safe fallback for tests
    that don't wire the new dep, 100% coverage on new code
  • make coverage — full repo, zero uncovered statement blocks
  • make integration — migration 0053 applies cleanly
  • make lint (scoped) — 0 issues
  • make e2e-policy-check — OK
  • Focused e2e run of Journey 3 (Manage Library) locally: 12/12 passed,
    including the new steps (seed CBX book → Comic Info panel visible
    under Auto → set library to Magazines via the modal → Comic Info
    panel hidden, screenshot posted when running in CI)

Closes bead bookshelf-qi21k.1 on merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi

## Summary Adds a per-library **Content Kind** (Auto/Comics/Magazines/Ebooks/Audiobooks) so a magazine stored as `.cbr` stops being treated as a comic, without touching the reader (Option A, confirmed 2026-08-09 on the parent epic bookshelf-qi21k). - **Storage:** `app_settings` keyed by library ID (`content_kind.library.<id>`) — the Grimmory `library` table gets no new column. Absent key = Auto = today's extension-based behavior, zero regression. - **Resolution:** `IsComic = hasCBXFile AND kind ∈ {Comics, Auto}`. - `internal/books.Show` gets an **optional variadic** content-kind resolver param so the ~30 existing `Show(...)` test call sites keep compiling unchanged; wired for real in `books/wire.go`. - `internal/books.WithLibraryContentKind` wraps the single-book metadata getter used by refetch / interactive candidate search / the step-through editor, so a Magazines/Ebooks/Audiobooks library's CBX files skip comic provider routing and comic-issue query building. - **Reader unchanged:** a `.cbr` still opens in the CBX page reader regardless of kind — nothing in the reader path was touched. - **UI:** Content Kind `<select>` on the library create/edit modal (`internal/library`), admin-gated per existing library management (`ManipulateLibrary`). - **Backfill:** migration `0053` sets the user's existing "Magazines" library to `kind=magazines` (data-only, no schema change); every other library defaults to Auto. - **Docs:** `docs/content/docs/your-library/libraries.md` gets a Content Kind section. - **Deferred (v2, per epic):** bulk/LLM sweep wiring (`internal/app/build_extended_deps.go`, `build_llm_deps.go`, `build_enrich_deps.go`) constructs its own separate `GetMetadataForRefetch` and is intentionally untouched here. ## Test plan - [x] `go test ./internal/settings/...` — new `library_content_kind.go` logic, 100% coverage - [x] `go test ./internal/books/...` — `WithLibraryContentKind` + `resolveShowIsComic` (Show's HTML and JSON branches), 100% coverage on new code, all ~5000 pre-existing specs unaffected - [x] `go test ./internal/library/...` — content-kind select rendering (real template), save-on-create/update, nil-safe fallback for tests that don't wire the new dep, 100% coverage on new code - [x] `make coverage` — full repo, zero uncovered statement blocks - [x] `make integration` — migration `0053` applies cleanly - [x] `make lint` (scoped) — 0 issues - [x] `make e2e-policy-check` — OK - [x] Focused e2e run of Journey 3 (Manage Library) locally: 12/12 passed, including the new steps (seed CBX book → Comic Info panel visible under Auto → set library to Magazines via the modal → Comic Info panel hidden, screenshot posted when running in CI) Closes bead bookshelf-qi21k.1 on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi
feat(library): per-library content kind decouples comic detection from CBX format (bookshelf-qi21k.1)
All checks were successful
/ E2E API (pull_request) Successful in 1m32s
/ Test Race (pull_request) Successful in 2m2s
/ JS Unit Tests (pull_request) Successful in 1m27s
/ Coverage (pull_request) Successful in 2m21s
/ Integration (pull_request) Successful in 2m25s
/ Hugo build (pull_request) Successful in 50s
/ Lint (pull_request) Successful in 3m26s
/ E2E Browser (pull_request) Successful in 5m11s
108d2973e2
Add a per-library Content Kind (Auto/Comics/Magazines/Ebooks/Audiobooks),
stored in app_settings (no new column on the Grimmory `library` table).
IsComic now resolves as hasCBXFile AND kind in {Auto, Comics}: a magazine
stored as .cbr in a Magazines-kind library stops being routed to comic
metadata providers and stops showing the Comic Info panel. The reader is
unchanged — a .cbr still opens in the CBX page reader regardless of kind.

- internal/settings/library_content_kind.go: kind storage + ResolveIsComic
- internal/books: WithLibraryContentKind wraps the single-book metadata
  getter (refetch/candidates/step-edit); Show gets an optional variadic
  content-kind resolver (keeps all ~30 existing test call sites compiling)
- internal/library: Content Kind <select> on the create/edit library modal,
  saved best-effort alongside the existing name/mode/formats fields
- internal/db/migrations/0053: data-only backfill of the user's existing
  "Magazines" library to kind=magazines; every other library defaults to
  Auto (absent key), zero regression
- e2e/browser: Journey 3 gains steps seeding a CBX book, confirming the
  Comic Info panel is visible under Auto and hidden after switching the
  library to Magazines, with a screenshot
- docs/content/docs/your-library/libraries.md: end-user Content Kind section

Bulk/LLM sweep wiring is intentionally untouched (separate GetMetadataForRefetch
construction in internal/app/build_*.go) — tracked as a v2 follow-up per the
qi21k epic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi
Author
Owner

[MAJOR] PR #1421 — no rendered screenshot to review
The PR description claims "Focused e2e run of Journey 3 (Manage Library) locally: 12/12 passed, ... screenshot posted when running in CI" — but this is explicitly conditional ("when running in CI"). Checking the Forgejo API: issues/1421/comments returns [] (0 comments) and issues/1421/assets returns [] (0 attachments). No screenshot has actually been posted to this PR by the go-rod harness. Per .claude/rules/review-standard.md UI review gate and post-ui-screenshots-in-pr, a templates/CSS-touching PR with no rendered screenshot attached is not review-ready — I cannot judge the rendered Content Kind select (label/select styling, spacing vs canonical .modal-dialog/.form-control, grouping vs --space-* tokens, accessibility) without seeing the actual pixels.
Fix: run the Journey 3 e2e in CI (or manually capture) and post the actual screenshot attachment(s) to this PR via the go-rod harness / Forgejo comment-attachment API, then re-request UI review. Do not rely on "will post when running in CI" as a substitute for a screenshot actually being present on the PR at review time.

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

[MAJOR] PR #1421 — no rendered screenshot to review The PR description claims "Focused e2e run of Journey 3 (Manage Library) locally: 12/12 passed, ... screenshot posted when running in CI" — but this is explicitly conditional ("when running in CI"). Checking the Forgejo API: `issues/1421/comments` returns `[]` (0 comments) and `issues/1421/assets` returns `[]` (0 attachments). No screenshot has actually been posted to this PR by the go-rod harness. Per `.claude/rules/review-standard.md` UI review gate and `post-ui-screenshots-in-pr`, a templates/CSS-touching PR with no rendered screenshot attached is not review-ready — I cannot judge the rendered Content Kind select (label/select styling, spacing vs canonical .modal-dialog/.form-control, grouping vs --space-* tokens, accessibility) without seeing the actual pixels. Fix: run the Journey 3 e2e in CI (or manually capture) and post the actual screenshot attachment(s) to this PR via the go-rod harness / Forgejo comment-attachment API, then re-request UI review. Do not rely on "will post when running in CI" as a substitute for a screenshot actually being present on the PR at review time. REVIEW VERDICT: 0 blocker, 1 major, 0 minor
@ -0,0 +1,13 @@
-- Backfill the content-kind override (bookshelf-qi21k) for the user's existing
Author
Owner

We don't need this backfill migration.

We don't need this backfill migration.
@ -43,0 +50,4 @@
{{end}}
</select>
<p class="form-hint">
Auto treats any comic-archive (.cbz/.cbr/.cb7/.cbt) file as a comic — today's default.
Author
Owner

Let's reword this to be more factual. What does "today's default" even mean?

Let's reword this to be more factual. What does "today's default" even mean?
Author
Owner

[MAJOR] internal/library/handler.go:344 (saveContentKindBestEffort) — invalid content_kind is silently swallowed instead of surfaced as 422, unlike the identical ErrUnknownProvider pattern it claims to mirror
updateHandler/createHandler call saveContentKindBestEffort, which on any error (including settings.ErrUnknownContentKind from an out-of-range value) only logs a Warn and returns 204/303 as if the save succeeded (see internal/library/handler.go:735-757). The doc comment claims this "mirrors" the metadata-provider save, but the actual provider-save code path (internal/library/handler.go:310-330, pre-existing) does the opposite for its analogous sentinel: it special-cases errors.Is(saveErr, settings.ErrUnknownProvider) and returns 422 Unprocessable Entity with the error re-rendered in the modal — it does NOT swallow it. content_kind_handler_test.go's only "save fails" spec asserts a generic error is swallowed (204) and never exercises ErrUnknownContentKind specifically to confirm intended behavior.
Impact: a JSON API client that submits an unrecognized content_kind (typo, stale client, future kind not yet supported by an older server) gets a 200/204 success while the field is silently NOT saved — masking a client bug and violating "validate at the boundary" (resilience-standard) and the same-file precedent it explicitly claims to follow.
Fix: in saveContentKindBestEffort (or its caller), special-case errors.Is(err, settings.ErrUnknownContentKind) the same way ErrUnknownProvider is handled — surface a 422 (JSON: return the error; HTML: re-render the modal with the error) — and keep only genuine infra failures (DB error) as best-effort/logged.

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

[MAJOR] internal/library/handler.go:344 (`saveContentKindBestEffort`) — invalid `content_kind` is silently swallowed instead of surfaced as 422, unlike the identical `ErrUnknownProvider` pattern it claims to mirror `updateHandler`/`createHandler` call `saveContentKindBestEffort`, which on any error (including `settings.ErrUnknownContentKind` from an out-of-range value) only logs a `Warn` and returns 204/303 as if the save succeeded (see `internal/library/handler.go:735-757`). The doc comment claims this "mirrors" the metadata-provider save, but the actual provider-save code path (`internal/library/handler.go:310-330`, pre-existing) does the opposite for its analogous sentinel: it special-cases `errors.Is(saveErr, settings.ErrUnknownProvider)` and returns `422 Unprocessable Entity` with the error re-rendered in the modal — it does NOT swallow it. `content_kind_handler_test.go`'s only "save fails" spec asserts a *generic* error is swallowed (204) and never exercises `ErrUnknownContentKind` specifically to confirm intended behavior. Impact: a JSON API client that submits an unrecognized `content_kind` (typo, stale client, future kind not yet supported by an older server) gets a `200/204 success` while the field is silently NOT saved — masking a client bug and violating "validate at the boundary" (resilience-standard) and the same-file precedent it explicitly claims to follow. Fix: in `saveContentKindBestEffort` (or its caller), special-case `errors.Is(err, settings.ErrUnknownContentKind)` the same way `ErrUnknownProvider` is handled — surface a 422 (JSON: return the error; HTML: re-render the modal with the error) — and keep only genuine infra failures (DB error) as best-effort/logged. REVIEW VERDICT: 0 blocker, 1 major, 0 minor
zombor changed title from Library content kind: decouple comic detection from CBX format (bookshelf-qi21k.1) to Library content kind: decouple comic detection from CBX format [shot:content-kind-screenshot] (bookshelf-qi21k.1) 2026-08-10 01:38:31 +00:00
zombor force-pushed bd-bookshelf-qi21k.1 from 108d2973e2
All checks were successful
/ E2E API (pull_request) Successful in 1m32s
/ Test Race (pull_request) Successful in 2m2s
/ JS Unit Tests (pull_request) Successful in 1m27s
/ Coverage (pull_request) Successful in 2m21s
/ Integration (pull_request) Successful in 2m25s
/ Hugo build (pull_request) Successful in 50s
/ Lint (pull_request) Successful in 3m26s
/ E2E Browser (pull_request) Successful in 5m11s
to f4e275341e
All checks were successful
/ Test Race (pull_request) Successful in 2m6s
/ Integration (pull_request) Successful in 1m58s
/ E2E API (pull_request) Successful in 1m35s
/ Coverage (pull_request) Successful in 2m24s
/ Hugo build (pull_request) Successful in 19s
/ Lint (pull_request) Successful in 3m4s
/ JS Unit Tests (pull_request) Successful in 1m5s
/ E2E Browser (pull_request) Successful in 4m42s
2026-08-10 01:38:39 +00:00
Compare
Author
Owner

Security review of PR #1421 (bd-bookshelf-qi21k.1) — per-library Content Kind.

Scope reviewed: internal/settings/library_content_kind.go, internal/library/handler.go + wire.go + routes.go, internal/books/library_content_kind.go + handler.go (Show), templates/pages/library_modal.html, migration 0053.

Findings:

No BLOCKER or MAJOR findings.

[MINOR] internal/library/handler.go:735 (saveContentKindBestEffort) — malformed content_kind from a JSON client is swallowed
A JSON caller (Content-Type: application/json) can submit an arbitrary content_kind string. SaveLibraryContentKind correctly rejects it via ErrUnknownContentKind, but saveContentKindBestEffort only logs a Warn and returns 204/201 as if the update succeeded — the caller has no way to know their content_kind was silently dropped. Not a security hole (the enum check in internal/settings/library_content_kind.go:isKnownContentKind still fully prevents an invalid value from ever being persisted or read back — no injection, no arbitrary state), but it's a confusing API contract. Consider surfacing a 422/validation error for JSON clients specifically (the HTML path can stay best-effort since it can only submit a valid value). [MINOR] internal/db/migrations/0053_backfill_magazines_library_content_kind.up.sql / .down.sql — name-based backfill match The backfill matches WHERE name = 'Magazines' (and the down migration matches the same). If an admin later creates or renames an unrelated library to "Magazines", a future re-run/rollback context could not accidentally affect it further (migrations run once), so this is very low risk — noted only for completeness, no action required. Positive notes (verified, not findings): Authz: PUT/PATCH /libraries/{id} (the only route that persists content_kind) is gated by g.ManipulateLibrary (permission_manipulate_library / admin) in internal/library/routes.go, in addition to the per-request checkLibraryOwnership scoping in updateHandler. A non-admin/non-authorized user cannot reach the write path. Enum validation: settings.SaveLibraryContentKind / isKnownContentKind reject any non-canonical value before persistence (internal/settings/library_content_kind.go); LoadLibraryContentKind also defaults any unrecognized stored value back to KindAuto defensively. Injection: the app_settings key is built with fmt.Sprintf("%s%d", ...) from an int64 libraryID (ContentKindKey) — no string concatenation of user input into the key, and all app_settings access goes through parameterized sqlc queries (UpsertAppSettingParams). Multi-user scoping: reads (loadContentKindForModal, GET edit-modal) and writes both go through checkLibraryOwnership against d.GetUserLibraryIDs before touching library {id}; a library outside the requester's access returns the standard not-found (no existence leak). books.WithLibraryContentKind / resolveShowIsComic resolve kind via the already-ownership-scoped book/library lookup — no direct pass-through of a request-supplied library id. Compat: migration 0053 adds no column to the Grimmory library table (data-only INSERT/DELETE against the existing app_settings key/value table), consistent with the project's schema-compat rule. CSP: the new Content Kind in templates/pages/library_modal.html uses only html/template auto-escaping, no inline style= attributes.

  • Secrets/PII: no new logging of sensitive data; content_kind/library_id values logged on failure paths are non-sensitive.
  • Verified via an actual local 3-way merge against the current origin/main (cf2da51c2) that the branch (cut before #1411/bookshelf-h4oh2.2 merged) merges cleanly and does NOT revert the unrelated bookdrop auto-fetch cover+title confidence gate — the two-way diff vs main looked like a large unrelated deletion but the real merge preserves both features correctly.
  • REVIEW VERDICT: 0 blocker, 0 major, 2 minor

    Security review of PR #1421 (bd-bookshelf-qi21k.1) — per-library Content Kind. Scope reviewed: internal/settings/library_content_kind.go, internal/library/handler.go + wire.go + routes.go, internal/books/library_content_kind.go + handler.go (Show), templates/pages/library_modal.html, migration 0053. Findings: No BLOCKER or MAJOR findings. [MINOR] internal/library/handler.go:735 (saveContentKindBestEffort) — malformed content_kind from a JSON client is swallowed A JSON caller (Content-Type: application/json) can submit an arbitrary `content_kind` string. SaveLibraryContentKind correctly rejects it via ErrUnknownContentKind, but saveContentKindBestEffort only logs a Warn and returns 204/201 as if the update succeeded — the caller has no way to know their content_kind was silently dropped. Not a security hole (the enum check in internal/settings/library_content_kind.go:isKnownContentKind still fully prevents an invalid value from ever being persisted or read back — no injection, no arbitrary state), but it's a confusing API contract. Consider surfacing a 422/validation error for JSON clients specifically (the HTML <select> path can stay best-effort since it can only submit a valid value). [MINOR] internal/db/migrations/0053_backfill_magazines_library_content_kind.up.sql / .down.sql — name-based backfill match The backfill matches `WHERE name = 'Magazines'` (and the down migration matches the same). If an admin later creates or renames an unrelated library to "Magazines", a future re-run/rollback context could not accidentally affect it further (migrations run once), so this is very low risk — noted only for completeness, no action required. Positive notes (verified, not findings): - Authz: PUT/PATCH /libraries/{id} (the only route that persists content_kind) is gated by `g.ManipulateLibrary` (permission_manipulate_library / admin) in internal/library/routes.go, in addition to the per-request `checkLibraryOwnership` scoping in updateHandler. A non-admin/non-authorized user cannot reach the write path. - Enum validation: settings.SaveLibraryContentKind / isKnownContentKind reject any non-canonical value before persistence (internal/settings/library_content_kind.go); LoadLibraryContentKind also defaults any unrecognized stored value back to KindAuto defensively. - Injection: the app_settings key is built with fmt.Sprintf("%s%d", ...) from an int64 libraryID (ContentKindKey) — no string concatenation of user input into the key, and all app_settings access goes through parameterized sqlc queries (UpsertAppSettingParams). - Multi-user scoping: reads (loadContentKindForModal, GET edit-modal) and writes both go through checkLibraryOwnership against d.GetUserLibraryIDs before touching library {id}; a library outside the requester's access returns the standard not-found (no existence leak). books.WithLibraryContentKind / resolveShowIsComic resolve kind via the already-ownership-scoped book/library lookup — no direct pass-through of a request-supplied library id. - Compat: migration 0053 adds no column to the Grimmory `library` table (data-only INSERT/DELETE against the existing app_settings key/value table), consistent with the project's schema-compat rule. - CSP: the new Content Kind <select> in templates/pages/library_modal.html uses only html/template auto-escaping, no inline style= attributes. - Secrets/PII: no new logging of sensitive data; content_kind/library_id values logged on failure paths are non-sensitive. - Verified via an actual local 3-way merge against the current origin/main (cf2da51c2) that the branch (cut before #1411/bookshelf-h4oh2.2 merged) merges cleanly and does NOT revert the unrelated bookdrop auto-fetch cover+title confidence gate — the two-way diff vs main looked like a large unrelated deletion but the real merge preserves both features correctly. REVIEW VERDICT: 0 blocker, 0 major, 2 minor
    Author
    Owner

    Security review of PR #1421 (bd-bookshelf-qi21k.1) — per-library Content Kind.

    Scope reviewed: internal/settings/library_content_kind.go, internal/library/handler.go + wire.go + routes.go, internal/books/library_content_kind.go + handler.go (Show), templates/pages/library_modal.html, migration 0053.

    Findings:

    No BLOCKER or MAJOR findings.

    [MINOR] internal/library/handler.go:735 (saveContentKindBestEffort) — malformed content_kind from a JSON client is swallowed
    A JSON caller (Content-Type: application/json) can submit an arbitrary content_kind string. SaveLibraryContentKind correctly rejects it via ErrUnknownContentKind, but saveContentKindBestEffort only logs a Warn and returns 204/201 as if the update succeeded — the caller has no way to know their content_kind was silently dropped. Not a security hole (the enum check in internal/settings/library_content_kind.go isKnownContentKind still fully prevents an invalid value from ever being persisted or read back — no injection, no arbitrary state), but it is a confusing API contract. Consider surfacing a 422/validation error for JSON clients specifically (the HTML select path can stay best-effort since it can only submit a valid value).

    [MINOR] internal/db/migrations/0053_backfill_magazines_library_content_kind.up.sql / .down.sql — name-based backfill match
    The backfill matches WHERE name = 'Magazines' (and the down migration matches the same). If an admin later creates or renames an unrelated library to Magazines, this is very low risk since migrations run once; noted only for completeness, no action required.

    Positive notes (verified, not findings):

    • Authz: PUT/PATCH /libraries/{id} (the only route that persists content_kind) is gated by g.ManipulateLibrary (permission_manipulate_library / admin) in internal/library/routes.go, in addition to the per-request checkLibraryOwnership scoping in updateHandler. A non-admin/non-authorized user cannot reach the write path.
    • Enum validation: settings.SaveLibraryContentKind / isKnownContentKind reject any non-canonical value before persistence (internal/settings/library_content_kind.go); LoadLibraryContentKind also defaults any unrecognized stored value back to KindAuto defensively.
    • Injection: the app_settings key is built with fmt.Sprintf from an int64 libraryID (ContentKindKey) — no string concatenation of user input into the key, and all app_settings access goes through parameterized sqlc queries (UpsertAppSettingParams).
    • Multi-user scoping: reads (loadContentKindForModal, GET edit-modal) and writes both go through checkLibraryOwnership against d.GetUserLibraryIDs before touching library {id}; a library outside the requester's access returns the standard not-found (no existence leak). books.WithLibraryContentKind / resolveShowIsComic resolve kind via the already-ownership-scoped book/library lookup — no direct pass-through of a request-supplied library id.
    • Compat: migration 0053 adds no column to the Grimmory library table (data-only INSERT/DELETE against the existing app_settings key/value table), consistent with the project's schema-compat rule.
    • CSP: the new Content Kind select in templates/pages/library_modal.html uses only html/template auto-escaping, no inline style= attributes.
    • Secrets/PII: no new logging of sensitive data; content_kind/library_id values logged on failure paths are non-sensitive.
    • Verified via an actual local 3-way merge against the current origin/main (cf2da51c2) that the branch (cut before #1411/bookshelf-h4oh2.2 merged) merges cleanly and does NOT revert the unrelated bookdrop auto-fetch cover+title confidence gate — the two-way diff vs main looked like a large unrelated deletion but the real merge preserves both features correctly.

    REVIEW VERDICT: 0 blocker, 0 major, 2 minor

    Security review of PR #1421 (bd-bookshelf-qi21k.1) — per-library Content Kind. Scope reviewed: internal/settings/library_content_kind.go, internal/library/handler.go + wire.go + routes.go, internal/books/library_content_kind.go + handler.go (Show), templates/pages/library_modal.html, migration 0053. Findings: No BLOCKER or MAJOR findings. [MINOR] internal/library/handler.go:735 (saveContentKindBestEffort) — malformed content_kind from a JSON client is swallowed A JSON caller (Content-Type: application/json) can submit an arbitrary content_kind string. SaveLibraryContentKind correctly rejects it via ErrUnknownContentKind, but saveContentKindBestEffort only logs a Warn and returns 204/201 as if the update succeeded — the caller has no way to know their content_kind was silently dropped. Not a security hole (the enum check in internal/settings/library_content_kind.go isKnownContentKind still fully prevents an invalid value from ever being persisted or read back — no injection, no arbitrary state), but it is a confusing API contract. Consider surfacing a 422/validation error for JSON clients specifically (the HTML select path can stay best-effort since it can only submit a valid value). [MINOR] internal/db/migrations/0053_backfill_magazines_library_content_kind.up.sql / .down.sql — name-based backfill match The backfill matches WHERE name = 'Magazines' (and the down migration matches the same). If an admin later creates or renames an unrelated library to Magazines, this is very low risk since migrations run once; noted only for completeness, no action required. Positive notes (verified, not findings): - Authz: PUT/PATCH /libraries/{id} (the only route that persists content_kind) is gated by g.ManipulateLibrary (permission_manipulate_library / admin) in internal/library/routes.go, in addition to the per-request checkLibraryOwnership scoping in updateHandler. A non-admin/non-authorized user cannot reach the write path. - Enum validation: settings.SaveLibraryContentKind / isKnownContentKind reject any non-canonical value before persistence (internal/settings/library_content_kind.go); LoadLibraryContentKind also defaults any unrecognized stored value back to KindAuto defensively. - Injection: the app_settings key is built with fmt.Sprintf from an int64 libraryID (ContentKindKey) — no string concatenation of user input into the key, and all app_settings access goes through parameterized sqlc queries (UpsertAppSettingParams). - Multi-user scoping: reads (loadContentKindForModal, GET edit-modal) and writes both go through checkLibraryOwnership against d.GetUserLibraryIDs before touching library {id}; a library outside the requester's access returns the standard not-found (no existence leak). books.WithLibraryContentKind / resolveShowIsComic resolve kind via the already-ownership-scoped book/library lookup — no direct pass-through of a request-supplied library id. - Compat: migration 0053 adds no column to the Grimmory library table (data-only INSERT/DELETE against the existing app_settings key/value table), consistent with the project's schema-compat rule. - CSP: the new Content Kind select in templates/pages/library_modal.html uses only html/template auto-escaping, no inline style= attributes. - Secrets/PII: no new logging of sensitive data; content_kind/library_id values logged on failure paths are non-sensitive. - Verified via an actual local 3-way merge against the current origin/main (cf2da51c2) that the branch (cut before #1411/bookshelf-h4oh2.2 merged) merges cleanly and does NOT revert the unrelated bookdrop auto-fetch cover+title confidence gate — the two-way diff vs main looked like a large unrelated deletion but the real merge preserves both features correctly. REVIEW VERDICT: 0 blocker, 0 major, 2 minor

    Library content kind — book Show page screenshot (magazines-kind-hides-comic-info-panel)

    magazines-kind-hides-comic-info-panel

    **Library content kind — book Show page screenshot** (magazines-kind-hides-comic-info-panel) ![magazines-kind-hides-comic-info-panel](/attachments/846e1e9a-52f1-4c49-b3a1-34d0eeee0faf)
    fix(library): 422 on ErrUnknownContentKind, drop backfill migration, factual modal copy (bookshelf-qi21k.1)
    All checks were successful
    / Test Race (pull_request) Successful in 1m50s
    / E2E API (pull_request) Successful in 1m20s
    / Lint (pull_request) Successful in 2m54s
    / Coverage (pull_request) Successful in 2m16s
    / Hugo build (pull_request) Successful in 29s
    / JS Unit Tests (pull_request) Successful in 53s
    / Integration (pull_request) Successful in 2m41s
    / E2E Browser (pull_request) Successful in 4m35s
    51d96619cb
    Addresses review findings on PR #1421:
    
    - internal/library/handler.go: an invalid content_kind (settings.
      ErrUnknownContentKind) was silently swallowed to a Warn log while the
      handler still returned success. Mirror the existing ErrUnknownProvider
      handling: create/update now surface a 422 (HTML modal re-render) / wrapped
      ErrValidation (JSON) for an unrecognised kind, extracted into
      applyContentKindOnCreate / applyContentKindOnUpdate to keep createHandler
      and updateHandler under the funlen/gocyclo gates. Only genuine infra
      failures remain best-effort/logged.
    - internal/db/migrations/0053_*: deleted. Content kind already defaults to
      Auto for every library via the ABSENCE of an app_settings row — no
      migration is needed. The Magazines library gets its kind set manually via
      the new UI.
    - templates/pages/library_modal.html: reworded the Content Kind help text
      from vague "today's default" to factual "Auto — detect comics from CBX
      archives (.cbz/.cbr/.cb7/.cbt); everything else uses generic metadata."
    
    Also rebases onto current main (post p8trd/#1402 + 007p6/#1415 screenshot-
    posting fixes) and adds a [shot:content-kind-screenshot] PR-title marker so
    CI posts the Journey-3 Comic-Info-panel screenshot automatically.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi

    Library content kind — book Show page screenshot (magazines-kind-hides-comic-info-panel)

    magazines-kind-hides-comic-info-panel

    **Library content kind — book Show page screenshot** (magazines-kind-hides-comic-info-panel) ![magazines-kind-hides-comic-info-panel](/attachments/20ec141c-17a7-4f2b-8cff-804971fa8c2c)
    Author
    Owner

    UI re-review of PR #1421 (branch bd-bookshelf-qi21k.1, head 51d96619c).

    Screenshot gate: RESOLVED. Both attachments (comments 17426, 17438) are valid 1280x800 PNGs of the book Show page for a magazines-kind library. Actually Read the pixels (not just the diff).

    1. RENDERED — Comic Info panel hidden cleanly for magazines-kind library:
      The Details tab renders Library/Read Status, File Path, action buttons (Read/Download/Fetch Metadata), then Similar Books/Files/Notes/Reading History sub-tabs with "No similar books found." No Comic Info heading, no empty .comic-metadata container, no orphaned label, no dead spacing gap. Source confirms why: templates/pages/books_show.html:802 gates the ENTIRE panel with {{if .ComicMeta}}, so when ComicMeta is nil the whole block (including the <h2>Comic Info</h2> heading) doesn't render at all — structurally absent, not just visually hidden. Clean.

    2. NEW CONTROL — Content Kind select in templates/pages/library_modal.html:43-59:
      Neither screenshot shows the modal itself (both are the book Show page), so this was assessed via template source. The new field:

      <div class="metadata-field">
        <label class="metadata-field-label" for="lem-content-kind">Content Kind</label>
        <select id="lem-content-kind" name="content_kind" class="metadata-field-input">...</select>
        <p class="form-hint">...</p>
      </div>
      

      is byte-for-byte the same canonical pattern as the sibling "Organization Mode" select a few lines above it (.metadata-field > .metadata-field-label + .metadata-field-input select), and the .form-hint paragraph matches the existing Allowed Formats/File Naming hint pattern elsewhere in the same file. No bespoke parallel class system. grep -n 'style="' templates/pages/library_modal.html returns zero matches — no inline style, no CSP concern.

    No findings.

    REVIEW VERDICT: 0 blocker, 0 major, 0 minor

    UI re-review of PR #1421 (branch bd-bookshelf-qi21k.1, head 51d96619c). Screenshot gate: RESOLVED. Both attachments (comments 17426, 17438) are valid 1280x800 PNGs of the book Show page for a magazines-kind library. Actually Read the pixels (not just the diff). 1. RENDERED — Comic Info panel hidden cleanly for magazines-kind library: The Details tab renders Library/Read Status, File Path, action buttons (Read/Download/Fetch Metadata), then Similar Books/Files/Notes/Reading History sub-tabs with "No similar books found." No Comic Info heading, no empty `.comic-metadata` container, no orphaned label, no dead spacing gap. Source confirms why: templates/pages/books_show.html:802 gates the ENTIRE panel with `{{if .ComicMeta}}`, so when ComicMeta is nil the whole block (including the `<h2>Comic Info</h2>` heading) doesn't render at all — structurally absent, not just visually hidden. Clean. 2. NEW CONTROL — Content Kind select in templates/pages/library_modal.html:43-59: Neither screenshot shows the modal itself (both are the book Show page), so this was assessed via template source. The new field: ``` <div class="metadata-field"> <label class="metadata-field-label" for="lem-content-kind">Content Kind</label> <select id="lem-content-kind" name="content_kind" class="metadata-field-input">...</select> <p class="form-hint">...</p> </div> ``` is byte-for-byte the same canonical pattern as the sibling "Organization Mode" select a few lines above it (`.metadata-field` > `.metadata-field-label` + `.metadata-field-input` select), and the `.form-hint` paragraph matches the existing Allowed Formats/File Naming hint pattern elsewhere in the same file. No bespoke parallel class system. `grep -n 'style="' templates/pages/library_modal.html` returns zero matches — no inline style, no CSP concern. No findings. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
    zombor force-pushed bd-bookshelf-qi21k.1 from 51d96619cb
    All checks were successful
    / Test Race (pull_request) Successful in 1m50s
    / E2E API (pull_request) Successful in 1m20s
    / Lint (pull_request) Successful in 2m54s
    / Coverage (pull_request) Successful in 2m16s
    / Hugo build (pull_request) Successful in 29s
    / JS Unit Tests (pull_request) Successful in 53s
    / Integration (pull_request) Successful in 2m41s
    / E2E Browser (pull_request) Successful in 4m35s
    to 1e16c066d0
    All checks were successful
    / Test Race (pull_request) Successful in 2m0s
    / Lint (pull_request) Successful in 2m29s
    / Coverage (pull_request) Successful in 2m50s
    / Integration (pull_request) Successful in 2m52s
    / Hugo build (pull_request) Successful in 1m3s
    / JS Unit Tests (pull_request) Successful in 1m4s
    / E2E API (pull_request) Successful in 1m54s
    / E2E Browser (pull_request) Successful in 4m39s
    2026-08-10 13:27:40 +00:00
    Compare

    Library content kind — book Show page screenshot (magazines-kind-hides-comic-info-panel)

    magazines-kind-hides-comic-info-panel

    **Library content kind — book Show page screenshot** (magazines-kind-hides-comic-info-panel) ![magazines-kind-hides-comic-info-panel](/attachments/ff2110a9-9954-4828-bfc8-07bff6d2a5be)
    zombor merged commit 0e7db10bdb into main 2026-08-10 13:42:26 +00:00
    Sign in to join this conversation.
    No reviewers
    No labels
    No milestone
    No project
    No assignees
    2 participants
    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!1421
    No description provided.