feat(settings): global metadata field×priority matrix (bookshelf-4y3y.2) #445

Merged
zombor merged 9 commits from bd-bookshelf-4y3y.2 into main 2026-06-09 02:30:32 +00:00
Owner

Summary

  • Extends MergeMetadataByFieldPriority from series-only to all 14 content fields + cover using type-safe mergeStringField, mergeSliceField, mergeCategoriesField, mergePointerField helpers
  • Adds a Grimmory-style global Field × Priority (P1–P4) matrix settings page at GET/PUT /settings/metadata-field-priority with per-field enabled toggle
  • Replaces old /settings/field-priority sidebar link with new URL (redirect kept for the legacy /settings/field-priority-matrix variant)
  • Persists via app_settings JSON only (no schema migration)
  • Adds field_priority_matrix_controller.js Stimulus controller + settings_metadata_field_priority.html template

Test plan

  • make build passes
  • make test passes (all packages, including internal/books, internal/settings)
  • golangci-lint run ./internal/settings/... ./internal/books/... — 0 issues
  • CI: unit + integration + e2e green
  • GET /settings/metadata-field-priority returns 200 HTML with matrix table
  • PUT /settings/metadata-field-priority returns 204 and persists
  • Old /settings/field-priority sidebar link redirects to new matrix page
  • 100% coverage gate passes

Closes bead bookshelf-4y3y.2 on merge.

## Summary - Extends `MergeMetadataByFieldPriority` from series-only to all 14 content fields + cover using type-safe `mergeStringField`, `mergeSliceField`, `mergeCategoriesField`, `mergePointerField` helpers - Adds a Grimmory-style global Field × Priority (P1–P4) matrix settings page at `GET/PUT /settings/metadata-field-priority` with per-field enabled toggle - Replaces old `/settings/field-priority` sidebar link with new URL (redirect kept for the legacy `/settings/field-priority-matrix` variant) - Persists via `app_settings` JSON only (no schema migration) - Adds `field_priority_matrix_controller.js` Stimulus controller + `settings_metadata_field_priority.html` template ## Test plan - [x] `make build` passes - [x] `make test` passes (all packages, including `internal/books`, `internal/settings`) - [x] `golangci-lint run ./internal/settings/... ./internal/books/...` — 0 issues - [ ] CI: unit + integration + e2e green - [ ] `GET /settings/metadata-field-priority` returns 200 HTML with matrix table - [ ] `PUT /settings/metadata-field-priority` returns 204 and persists - [ ] Old `/settings/field-priority` sidebar link redirects to new matrix page - [ ] 100% coverage gate passes Closes bead bookshelf-4y3y.2 on merge.
feat(settings): add global metadata field×priority matrix (4y3y.2)
Some checks failed
/ Lint (pull_request) Successful in 2m13s
/ JS Unit Tests (pull_request) Successful in 59s
/ Test (pull_request) Failing after 3m16s
/ Integration (pull_request) Successful in 3m47s
/ E2E Browser (pull_request) Failing after 3m20s
/ E2E API (pull_request) Failing after 4m42s
f34a2a55b9
Extends MergeMetadataByFieldPriority from series-only to all 14
content fields + cover; adds a Grimmory-style field×provider matrix
settings page with per-field enable toggle persisted via app_settings.

- Rewrite MergeMetadataByFieldPriority for title/subtitle/description/
  authors/publisher/publishedDate/seriesName/seriesNumber/seriesTotal/
  isbn13/isbn10/language/categories/pageCount/cover fields using
  mergeStringField, mergeSliceField, mergeCategoriesField, mergePointerField
- Add fallback parameter to mergeSliceField and mergeCategoriesField so
  disabled/locked fields preserve existing values instead of nil
- Extend settings.FieldOptions, EnabledFields, books.RefreshFieldOptions,
  books.RefreshEnabledFields and SettingsOptsToRefreshOpts for all 15 fields
- New GET/PUT /settings/metadata-field-priority routes with MatrixDeps,
  GetMetadataFieldPriorityHandler, UpdateMetadataFieldPriorityHandler
- Redirect GET /settings/field-priority-matrix to new URL
- New settings_metadata_field_priority.html template with per-row
  enabled checkbox + P1-P4 provider selects
- New field_priority_matrix_controller.js Stimulus controller
- Update base.html sidebar link and add controller <script> tag
- Full test coverage: merge_test.go (all 15 fields), matrix_handler_test.go,
  wire_test.go (content fields), e2e/api/settings_metadata_field_priority_test.go

Closes bead bookshelf-4y3y.2 on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(coverage): achieve 100% coverage gate on all affected packages
Some checks failed
/ JS Unit Tests (pull_request) Successful in 33s
/ Lint (pull_request) Successful in 3m13s
/ Test (pull_request) Successful in 3m36s
/ E2E Browser (pull_request) Failing after 4m36s
/ Integration (pull_request) Successful in 5m15s
/ E2E API (pull_request) Failing after 5m18s
d1269058b1
- settings: export getFieldEnabled/fpSlot helpers via export_test.go to
  exercise the default branch (unknown key) and nil/out-of-range slot paths
- books: add getColumnPrefs error-fallback test in ListHandler; add
  position > duration clamping test in SaveProgressHandler; add unknown-type
  Score test in decodeScore
- wfengine: refactor newRealBackend to delegate MySQL construction to an
  injectable newMysqlBackend var, allowing unit tests to exercise the
  success path (return b, nil) without a live MySQL connection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zombor force-pushed bd-bookshelf-4y3y.2 from d1269058b1
Some checks failed
/ JS Unit Tests (pull_request) Successful in 33s
/ Lint (pull_request) Successful in 3m13s
/ Test (pull_request) Successful in 3m36s
/ E2E Browser (pull_request) Failing after 4m36s
/ Integration (pull_request) Successful in 5m15s
/ E2E API (pull_request) Failing after 5m18s
to 171a287d60
All checks were successful
/ JS Unit Tests (pull_request) Successful in 49s
/ E2E Browser (pull_request) Successful in 3m3s
/ Lint (pull_request) Successful in 2m24s
/ Test (pull_request) Successful in 3m32s
/ E2E API (pull_request) Successful in 3m37s
/ Integration (pull_request) Successful in 5m32s
2026-06-09 00:35:30 +00:00
Compare
Author
Owner

Code Review (bot)

PR #445 — bookshelf-4y3y.2: unified global Field×Priority metadata matrix


Phase 0: DEMO Verification

No DEMO block was required for this bead type (settings UI + merge extension). CI is green per the review dispatch.


Findings

[MAJOR] internal/books/merge.go:129-145 — mergeSliceField/mergeCategoriesField use out.* (primary) as fallback; PersistMetadata only checks *Locked, not EnabledFields

When Authors or Categories is disabled (not locked) in the matrix, mergeSliceField returns out.Authors / out.Categories — the first-winner provider's values (i.e. primary.Authors). These values then flow into PersistMetadata, which only gates the DB write on existing.AuthorsLocked / existing.CategoriesLocked (metadata_store.go:530-535). If the field is disabled but not locked, PersistMetadata calls syncAuthors / syncCategories with primary.Authors and overwrites the existing DB rows — the opposite of what "disabled" should mean.

Scalar fields (Title, Publisher, etc.) correctly return existingVal when disabled, so their DB values are preserved. Slice fields return primary.* which gets written. The tests at merge_test.go:500+ assert "preserves primary authors unchanged" which describes the wrong semantics — "disabled" should preserve the existing DB authors, not the primary provider's authors.

Fix options: (a) thread existing.Authors []string through MetadataRow so mergeSliceField can return the true DB value when disabled/locked, or (b) add a EnabledFields.Authors / .Categories guard in PersistMetadata alongside the existing AuthorsLocked guard, or (c) change the fallback in mergeSliceField to nil / empty slice when disabled, and guard syncAuthors on len(m.Authors) > 0 in PersistMetadata.

[MAJOR] internal/settings/routes.go:15-17 / internal/settings/matrix_handler.go (whole file) — Bead spec says "REPLACE the current rating-only /settings/field-priority page (old URL redirected)" but old URL is not redirected; redirect is registered for a never-existed URL

On main, /settings/field-priority was the only settings page (rating-only). The bead spec says this PR replaces it with the new matrix page and redirects the old URL. What actually landed:

  1. /settings/field-priority still exists (unchanged ListFieldPriorityHandler), no longer linked in the sidebar nav but fully accessible — the old rating-only page coexists with the new matrix.
  2. The redirect added is GET /settings/field-priority-matrix → /settings/metadata-field-priority. There was no /settings/field-priority-matrix URL on main, so this redirect has no legacy traffic to serve.
  3. The rating fields (HardcoverRating, GoogleBooksRating, OpenLibraryRating) are explicitly excluded from contentProviders and from fieldDefs, so they do not appear in the new matrix as the spec requires ("rating becomes one row").

Result: two separate pages for provider priority settings now coexist, with no migration path between them. Users who bookmarked /settings/field-priority see the old page without indication that a replacement exists. Rating configuration is split across two pages.

Fix: either (a) add http.Redirect for GET /settings/field-priority/settings/metadata-field-priority, remove or repurpose ListFieldPriorityHandler, and add rating fields to fieldDefs + contentProviders; or (b) leave the two pages coexisting and update the bead description to reflect the intentional difference in scope.

[MINOR] internal/settings/matrix_handler.go:381-386 — applyMatrixUpdate does not validate ReplaceMode value

Any string is accepted as ReplaceMode in a PUT request. The merge engine treats anything other than the literal string "REPLACE" as REPLACE_MISSING (merge.go:76: if replaceMode != "REPLACE"), so a client submitting "REPLACE_ALL" silently gets REPLACE_MISSING semantics. Not a security issue (no privilege escalation) but a confusing silent fallback. Fix: validate that ReplaceMode is one of the two known values before persisting.

[MINOR] internal/settings/matrix_handler.go:33-42 — FieldRow has no json: struct tags; serializes with capitalized field names ("Key", "P1", "Label", etc.)

All other JSON-serialized types in this codebase use lowercase json:"…" tags. FieldRow exposes "Key", "P1", "P2", "P3", "P4", "Label", "Enabled" with Go-default PascalCase keys. The e2e test (e2e/api/settings_metadata_field_priority_test.go:38) decodes with json:"Key" to match. This works but deviates from API naming conventions. Fix: add json:"key", json:"p1", etc. tags; update the corresponding test decode struct and the e2e test decode struct.


Phase 2 Highlights (passing checks)

  • merge correctness (scalar fields): mergeStringField / mergePointerField correctly implement the 5-step spec (disabled → existing, locked → existing, walk chain, apply replace-mode, fallback to existing). REPLACE_MISSING preserve-on-non-empty is correct (line 76: replaceMode != "REPLACE" && existingVal != "").
  • mergeCategories union: mergeCategoriesUnion correctly deduplicates in encounter order with a seen map.
  • FieldOptions/EnabledFields reconciliation: Both settings.FieldOptions (the persisted shape) and books.RefreshFieldOptions (the in-process shape) are expanded to the same 14+cover set. SettingsOptsToRefreshOpts maps every field in wire.go:607-649. The nil-FieldProvider → empty-FieldProvider default is correct (no chain → preserves existing).
  • defaultQuickBookMatch alignment: Both settings.defaultQuickBookMatch and books.DefaultMetadataRefreshOptions now use hardcover P1 / open-library P2 / google-books P3 for all fields. Fallback and persisted default match.
  • GET JSON response shape: 15 fields returned in fieldDefs order; cover is last. e2e and unit tests verify this.
  • PUT persistence round-trip: applyMatrixUpdateSave (UpsertAppSetting) → Load round-trip is tested in e2e/api via GET-after-PUT assertion.
  • e2e enrich test correction: Seeding title="" + asserting it fills to "Enriched By Workflow" correctly proves the REPLACE_MISSING fill-empty path. The regression guard (workflow no-op times out with "") is valid.
  • browser lock test restructure: Two Describe blocks (locked + unlocked-empty) cleanly separate the two behaviors. The unlocked test now seeds title="" so REPLACE_MISSING fills it — the assertion ContainSubstring("Refetched Title") is non-vacuous.
  • Stimulus controller: File field_priority_matrix_controller.js, registered as "field-priority-matrix", class window.FieldPriorityMatrixController extends window.StimulusController — all conventions correct. saveUrlValue is a Stimulus value; save button uses data-action="click->field-priority-matrix#save".
  • no schema changes: All persistence goes through app_settings via UpsertAppSetting. Grimmory-schema guard is not affected.
  • wfengine newMysqlBackend extraction: Straightforward testability refactor; defaultNewMysqlBackend preserves identical production behavior.
  • sortKeyIndex refactor (store.go): Switch → map lookup; behavior identical.

REVIEW VERDICT: 0 blocker, 2 major, 2 minor

## Code Review (bot) **PR #445 — bookshelf-4y3y.2: unified global Field×Priority metadata matrix** --- ### Phase 0: DEMO Verification No DEMO block was required for this bead type (settings UI + merge extension). CI is green per the review dispatch. --- ### Findings [MAJOR] internal/books/merge.go:129-145 — `mergeSliceField`/`mergeCategoriesField` use `out.*` (primary) as fallback; `PersistMetadata` only checks `*Locked`, not `EnabledFields` When `Authors` or `Categories` is **disabled** (not locked) in the matrix, `mergeSliceField` returns `out.Authors` / `out.Categories` — the first-winner provider's values (i.e. `primary.Authors`). These values then flow into `PersistMetadata`, which only gates the DB write on `existing.AuthorsLocked` / `existing.CategoriesLocked` (metadata_store.go:530-535). If the field is disabled but not locked, `PersistMetadata` calls `syncAuthors` / `syncCategories` with `primary.Authors` and **overwrites the existing DB rows** — the opposite of what "disabled" should mean. Scalar fields (`Title`, `Publisher`, etc.) correctly return `existingVal` when disabled, so their DB values are preserved. Slice fields return `primary.*` which gets written. The tests at merge_test.go:500+ assert "preserves primary authors unchanged" which describes the wrong semantics — "disabled" should preserve the **existing DB** authors, not the primary provider's authors. Fix options: (a) thread `existing.Authors []string` through `MetadataRow` so `mergeSliceField` can return the true DB value when disabled/locked, or (b) add a `EnabledFields.Authors / .Categories` guard in `PersistMetadata` alongside the existing `AuthorsLocked` guard, or (c) change the fallback in `mergeSliceField` to `nil` / empty slice when disabled, and guard `syncAuthors` on `len(m.Authors) > 0` in `PersistMetadata`. [MAJOR] internal/settings/routes.go:15-17 / internal/settings/matrix_handler.go (whole file) — Bead spec says "REPLACE the current rating-only `/settings/field-priority` page (old URL redirected)" but old URL is not redirected; redirect is registered for a never-existed URL On `main`, `/settings/field-priority` was the only settings page (rating-only). The bead spec says this PR replaces it with the new matrix page and redirects the old URL. What actually landed: 1. `/settings/field-priority` **still exists** (unchanged `ListFieldPriorityHandler`), no longer linked in the sidebar nav but fully accessible — the old rating-only page coexists with the new matrix. 2. The redirect added is `GET /settings/field-priority-matrix → /settings/metadata-field-priority`. There was no `/settings/field-priority-matrix` URL on `main`, so this redirect has no legacy traffic to serve. 3. The rating fields (`HardcoverRating`, `GoogleBooksRating`, `OpenLibraryRating`) are explicitly excluded from `contentProviders` and from `fieldDefs`, so they do **not** appear in the new matrix as the spec requires ("rating becomes one row"). Result: two separate pages for provider priority settings now coexist, with no migration path between them. Users who bookmarked `/settings/field-priority` see the old page without indication that a replacement exists. Rating configuration is split across two pages. Fix: either (a) add `http.Redirect` for `GET /settings/field-priority` → `/settings/metadata-field-priority`, remove or repurpose `ListFieldPriorityHandler`, and add rating fields to `fieldDefs` + `contentProviders`; or (b) leave the two pages coexisting and update the bead description to reflect the intentional difference in scope. [MINOR] internal/settings/matrix_handler.go:381-386 — `applyMatrixUpdate` does not validate `ReplaceMode` value Any string is accepted as `ReplaceMode` in a PUT request. The merge engine treats anything other than the literal string `"REPLACE"` as `REPLACE_MISSING` (merge.go:76: `if replaceMode != "REPLACE"`), so a client submitting `"REPLACE_ALL"` silently gets `REPLACE_MISSING` semantics. Not a security issue (no privilege escalation) but a confusing silent fallback. Fix: validate that `ReplaceMode` is one of the two known values before persisting. [MINOR] internal/settings/matrix_handler.go:33-42 — `FieldRow` has no `json:` struct tags; serializes with capitalized field names (`"Key"`, `"P1"`, `"Label"`, etc.) All other JSON-serialized types in this codebase use lowercase `json:"…"` tags. `FieldRow` exposes `"Key"`, `"P1"`, `"P2"`, `"P3"`, `"P4"`, `"Label"`, `"Enabled"` with Go-default PascalCase keys. The e2e test (e2e/api/settings_metadata_field_priority_test.go:38) decodes with `json:"Key"` to match. This works but deviates from API naming conventions. Fix: add `json:"key"`, `json:"p1"`, etc. tags; update the corresponding test decode struct and the e2e test decode struct. --- ### Phase 2 Highlights (passing checks) - **merge correctness (scalar fields)**: `mergeStringField` / `mergePointerField` correctly implement the 5-step spec (disabled → existing, locked → existing, walk chain, apply replace-mode, fallback to existing). REPLACE_MISSING preserve-on-non-empty is correct (line 76: `replaceMode != "REPLACE" && existingVal != ""`). - **mergeCategories union**: `mergeCategoriesUnion` correctly deduplicates in encounter order with a `seen` map. - **FieldOptions/EnabledFields reconciliation**: Both `settings.FieldOptions` (the persisted shape) and `books.RefreshFieldOptions` (the in-process shape) are expanded to the same 14+cover set. `SettingsOptsToRefreshOpts` maps every field in `wire.go:607-649`. The nil-FieldProvider → empty-FieldProvider default is correct (no chain → preserves existing). - **defaultQuickBookMatch alignment**: Both `settings.defaultQuickBookMatch` and `books.DefaultMetadataRefreshOptions` now use `hardcover P1 / open-library P2 / google-books P3` for all fields. Fallback and persisted default match. - **GET JSON response shape**: 15 fields returned in `fieldDefs` order; cover is last. e2e and unit tests verify this. - **PUT persistence round-trip**: `applyMatrixUpdate` → `Save` (UpsertAppSetting) → `Load` round-trip is tested in e2e/api via GET-after-PUT assertion. - **e2e enrich test correction**: Seeding `title=""` + asserting it fills to `"Enriched By Workflow"` correctly proves the REPLACE_MISSING fill-empty path. The regression guard (workflow no-op times out with `""`) is valid. - **browser lock test restructure**: Two `Describe` blocks (locked + unlocked-empty) cleanly separate the two behaviors. The unlocked test now seeds `title=""` so REPLACE_MISSING fills it — the assertion `ContainSubstring("Refetched Title")` is non-vacuous. - **Stimulus controller**: File `field_priority_matrix_controller.js`, registered as `"field-priority-matrix"`, class `window.FieldPriorityMatrixController extends window.StimulusController` — all conventions correct. `saveUrlValue` is a Stimulus value; save button uses `data-action="click->field-priority-matrix#save"`. - **no schema changes**: All persistence goes through `app_settings` via `UpsertAppSetting`. Grimmory-schema guard is not affected. - **wfengine `newMysqlBackend` extraction**: Straightforward testability refactor; `defaultNewMysqlBackend` preserves identical production behavior. - **`sortKeyIndex` refactor** (store.go): Switch → map lookup; behavior identical. --- REVIEW VERDICT: 0 blocker, 2 major, 2 minor
Author
Owner

Security Review (bot)

PR #445 — Global metadata Field×Priority matrix settings page (bead bookshelf-4y3y.2)


Findings

[MAJOR] internal/settings/routes.go:11-18 — New /settings/metadata-field-priority routes lack AdminRequired guard; any authenticated non-admin user can read and overwrite global metadata settings

RegisterRoutes registers both GET /settings/metadata-field-priority and PUT /settings/metadata-field-priority via eh.Wrap(...) only — no AdminRequired middleware is applied. Compare with POST /admin/covers/refresh-all and all /admin/users/* routes, which wrap handlers with adminRequired(eh.Wrap(h)). The entire settings package (Wire, RegisterRoutes) never receives d.AdminRequired from appwire.Deps — it is available in Deps but simply not passed or used.

A regular (non-admin) authenticated user can therefore:

  • GET /settings/metadata-field-priority — read global priority matrix
  • PUT /settings/metadata-field-priority — overwrite global metadata merge settings, including disabling all fields or redirecting all metadata to a provider of their choice

The pre-existing /settings/field-priority and /settings/metadata-providers routes have the same gap (pre-existing, not introduced by this PR), but this PR adds a new state-mutating PUT that makes the impact worse.

Fix: thread adminRequired func(http.Handler) http.Handler into RegisterRoutes and apply it to all /settings/* handlers (or at minimum the new matrix routes and the existing PUT), matching the pattern in internal/cover/routes.go and internal/users/admin_handler.go.


[MINOR] static/js/controllers/field_priority_matrix_controller.js:30-36 — CSRF token omitted from PUT fetch call

The field_priority_matrix_controller.js save() method issues fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" }, ... }) without including the X-CSRF-Token header. Every other state-mutating Stimulus controller in this codebase (provider_settings_controller.js:47-54, provider_priority_controller.js:29-36) reads the CSRF token from document.querySelector('meta[name="csrf-token"]') and passes it as "X-CSRF-Token": csrf. The middleware's double-submit CSRF check (internal/middleware/csrf.go:100-106) will reject this PUT with 403 on any real browser session that has the CSRF cookie set.

Note: this is a correctness/functional bug (the save button silently fails), but it is also technically a CSRF gap for any non-browser API client that can reach the endpoint without the cookie (which is blocked by the AdminRequired gap above anyway). Fix: add const csrfMeta = document.querySelector('meta[name="csrf-token"]'); const csrf = csrfMeta ? csrfMeta.getAttribute("content") : ""; and include "X-CSRF-Token": csrf in the fetch headers, matching provider_settings_controller.js.


Confirmed clean

SSRF (cover row): internal/cover/download.go applies a comprehensive defense-in-depth: scheme allowlist (http/https only, ErrBadScheme), custom safeDialContext that resolves the hostname and rejects all private/loopback/link-local/reserved CIDRs (10/8, 172.16/12, 192.168/16, 169.254/16, fc00::/7, fe80::/10, 127/8, 0/8, ::1, :: — ErrPrivateAddress), redirect refusal (CheckRedirect: ErrUseLastResponse), body size cap, Content-Type allowlist, and magic-byte validation. The resolved CoverURL from MergeMetadataByFieldPriority flows through persistmetadata_store.go → DB → cover generation worker, which calls DownloadCoverProduction (the safe variant). This SSRF guard was already in place and is applied to this new code path.

app_settings write scope: SaveQuickBookMatch hard-codes the key "QUICK_BOOK_MATCH" — the PUT handler cannot write to any other app_settings key regardless of request content.

Injection / template rendering: All field labels and provider names rendered in settings_metadata_field_priority.html come from the server-side fieldDefs and contentProviders slices (compile-time constants), not from user input or DB values. html/template auto-escaping applies. No template.HTML or safeHTML casts are present in the new code. Provider IDs stored in FieldProvider (P1–P4) are string-stored settings values used only as map keys into allResults map[string]metadata.Metadata — no SQL interpolation, no shell exec, no template rendering of raw user-supplied provider strings.

Provider/field key validation: applyMatrixUpdate passes unknown fe.Key values through setFieldProvider/setFieldEnabled switch statements that silently no-op on unrecognized keys. Unknown P1P4 provider IDs that don't match any allResults key are harmlessly skipped in mergeStringField/mergeSliceField. No injection risk; the worst outcome is a misconfigured priority chain that yields empty results.

AuthN (authentication): All /settings/* routes sit behind the global users.AuthMiddleware (see app.go:404), so unauthenticated requests are rejected before reaching any handler. The gap is AuthZ (admin role check), not AuthN.


REVIEW VERDICT: 0 blocker, 1 major, 1 minor

## Security Review (bot) **PR #445** — Global metadata Field×Priority matrix settings page (bead bookshelf-4y3y.2) --- ### Findings [MAJOR] internal/settings/routes.go:11-18 — New `/settings/metadata-field-priority` routes lack `AdminRequired` guard; any authenticated non-admin user can read and overwrite global metadata settings `RegisterRoutes` registers both `GET /settings/metadata-field-priority` and `PUT /settings/metadata-field-priority` via `eh.Wrap(...)` only — no `AdminRequired` middleware is applied. Compare with `POST /admin/covers/refresh-all` and all `/admin/users/*` routes, which wrap handlers with `adminRequired(eh.Wrap(h))`. The entire settings package (`Wire`, `RegisterRoutes`) never receives `d.AdminRequired` from `appwire.Deps` — it is available in `Deps` but simply not passed or used. A regular (non-admin) authenticated user can therefore: - `GET /settings/metadata-field-priority` — read global priority matrix - `PUT /settings/metadata-field-priority` — overwrite global metadata merge settings, including disabling all fields or redirecting all metadata to a provider of their choice The pre-existing `/settings/field-priority` and `/settings/metadata-providers` routes have the same gap (pre-existing, not introduced by this PR), but this PR adds a new state-mutating PUT that makes the impact worse. Fix: thread `adminRequired func(http.Handler) http.Handler` into `RegisterRoutes` and apply it to all `/settings/*` handlers (or at minimum the new matrix routes and the existing PUT), matching the pattern in `internal/cover/routes.go` and `internal/users/admin_handler.go`. --- [MINOR] static/js/controllers/field_priority_matrix_controller.js:30-36 — CSRF token omitted from PUT fetch call The `field_priority_matrix_controller.js` `save()` method issues `fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" }, ... })` without including the `X-CSRF-Token` header. Every other state-mutating Stimulus controller in this codebase (`provider_settings_controller.js:47-54`, `provider_priority_controller.js:29-36`) reads the CSRF token from `document.querySelector('meta[name="csrf-token"]')` and passes it as `"X-CSRF-Token": csrf`. The middleware's double-submit CSRF check (`internal/middleware/csrf.go:100-106`) will reject this PUT with 403 on any real browser session that has the CSRF cookie set. Note: this is a correctness/functional bug (the save button silently fails), but it is also technically a CSRF gap for any non-browser API client that can reach the endpoint without the cookie (which is blocked by the `AdminRequired` gap above anyway). Fix: add `const csrfMeta = document.querySelector('meta[name="csrf-token"]'); const csrf = csrfMeta ? csrfMeta.getAttribute("content") : "";` and include `"X-CSRF-Token": csrf` in the fetch headers, matching `provider_settings_controller.js`. --- ### Confirmed clean **SSRF (cover row):** `internal/cover/download.go` applies a comprehensive defense-in-depth: scheme allowlist (`http`/`https` only, `ErrBadScheme`), custom `safeDialContext` that resolves the hostname and rejects all private/loopback/link-local/reserved CIDRs (10/8, 172.16/12, 192.168/16, 169.254/16, fc00::/7, fe80::/10, 127/8, 0/8, ::1, :: — `ErrPrivateAddress`), redirect refusal (`CheckRedirect: ErrUseLastResponse`), body size cap, Content-Type allowlist, and magic-byte validation. The resolved `CoverURL` from `MergeMetadataByFieldPriority` flows through `persist` → `metadata_store.go` → DB → cover generation worker, which calls `DownloadCoverProduction` (the safe variant). This SSRF guard was already in place and is applied to this new code path. **app_settings write scope:** `SaveQuickBookMatch` hard-codes the key `"QUICK_BOOK_MATCH"` — the PUT handler cannot write to any other `app_settings` key regardless of request content. **Injection / template rendering:** All field labels and provider names rendered in `settings_metadata_field_priority.html` come from the server-side `fieldDefs` and `contentProviders` slices (compile-time constants), not from user input or DB values. `html/template` auto-escaping applies. No `template.HTML` or `safeHTML` casts are present in the new code. Provider IDs stored in `FieldProvider` (P1–P4) are string-stored settings values used only as map keys into `allResults map[string]metadata.Metadata` — no SQL interpolation, no shell exec, no template rendering of raw user-supplied provider strings. **Provider/field key validation:** `applyMatrixUpdate` passes unknown `fe.Key` values through `setFieldProvider`/`setFieldEnabled` switch statements that silently no-op on unrecognized keys. Unknown `P1`–`P4` provider IDs that don't match any `allResults` key are harmlessly skipped in `mergeStringField`/`mergeSliceField`. No injection risk; the worst outcome is a misconfigured priority chain that yields empty results. **AuthN (authentication):** All `/settings/*` routes sit behind the global `users.AuthMiddleware` (see `app.go:404`), so unauthenticated requests are rejected before reaching any handler. The gap is AuthZ (admin role check), not AuthN. --- REVIEW VERDICT: 0 blocker, 1 major, 1 minor
zombor force-pushed bd-bookshelf-4y3y.2 from 171a287d60
All checks were successful
/ JS Unit Tests (pull_request) Successful in 49s
/ E2E Browser (pull_request) Successful in 3m3s
/ Lint (pull_request) Successful in 2m24s
/ Test (pull_request) Successful in 3m32s
/ E2E API (pull_request) Successful in 3m37s
/ Integration (pull_request) Successful in 5m32s
to cf4bb72dec
Some checks failed
/ JS Unit Tests (pull_request) Successful in 19s
/ Lint (pull_request) Successful in 2m22s
/ Test (pull_request) Successful in 2m59s
/ E2E Browser (pull_request) Successful in 4m23s
/ E2E API (pull_request) Failing after 4m34s
/ Integration (pull_request) Successful in 4m51s
2026-06-09 01:18:07 +00:00
Compare
fix(e2e): add Accept: application/json to unauthenticated PUT test
All checks were successful
/ JS Unit Tests (pull_request) Successful in 18s
/ Lint (pull_request) Successful in 1m54s
/ Test (pull_request) Successful in 2m26s
/ Integration (pull_request) Successful in 3m7s
/ E2E Browser (pull_request) Successful in 3m8s
/ E2E API (pull_request) Successful in 3m24s
3626093eed
The denyRequest helper sends 401 only to JSON clients (Accept: application/json).
The unauthenticated PUT test was missing that header, causing denyRequest to
redirect to /login (302) instead of returning 401. The http.Client then followed
the redirect, landing on the /login page (200), failing the 401 assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
style(settings): add CSS for metadata field×priority matrix
Some checks failed
/ JS Unit Tests (pull_request) Successful in 22s
/ Test (pull_request) Has been cancelled
/ Integration (pull_request) Has been cancelled
/ Lint (pull_request) Has been cancelled
/ E2E Browser (pull_request) Has been cancelled
/ E2E API (pull_request) Has been cancelled
7dfc4d5519
Styles the matrix table, per-row provider selects, enabled checkboxes,
and the set-all controls used by settings_metadata_field_priority.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(settings): add Set All controls to field-priority matrix
Some checks failed
/ Test (pull_request) Has been cancelled
/ E2E Browser (pull_request) Has been cancelled
/ E2E API (pull_request) Has been cancelled
/ Integration (pull_request) Has been cancelled
/ Lint (pull_request) Has been cancelled
/ JS Unit Tests (pull_request) Has been cancelled
cac841371c
- Add a "Set All" header row with per-column provider selects that fan a
  chosen provider (or Clear All) across every field row's column.
- Rename priority headers/aria-labels to 1st–4th Priority for clarity.
- Rewrite the controller to the project-standard self-registering IIFE
  pattern (window.Stimulus.Controller) matching every other controller.
- Add JS unit tests covering setAll (apply/clear/reset) and save payload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(settings): add fallback for undefined --surface in matrix CSS
All checks were successful
/ JS Unit Tests (pull_request) Successful in 25s
/ Lint (pull_request) Successful in 2m44s
/ Test (pull_request) Successful in 3m23s
/ E2E API (pull_request) Successful in 3m36s
/ E2E Browser (pull_request) Successful in 4m36s
/ Integration (pull_request) Successful in 4m40s
a84aad7f86
--surface is not defined in main.css's :root, so the matrix header and
set-all rows rendered with no background. Add a #1a1d27 fallback to
var(--surface) matching the existing --bg-card value.

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

Screenshot available locally at /tmp/field_priority_matrix.png — see PR description for details.

Screenshot available locally at /tmp/field_priority_matrix.png — see PR description for details.
Author
Owner

Matrix UI polish + Set All row — screenshot

Metadata Field Priority Matrix

What's visible:

  • Field labels are nowrap — "Published Date", "Series Name", "Series Number", "Series Total" all render on one line
  • Priority column selects show full provider names: "Hardcover", "Open Library", "Google Books", "— none —" (no truncation)
  • Set All: top row — per-column dropdowns to fan a provider across all fields in that priority column, or "Clear All" to reset to none
  • Column headers renamed to "1st Priority / 2nd Priority / 3rd Priority / 4th Priority" for clarity
  • 900px max-width settings-section--matrix gives the table room to breathe

Changes:

  • static/css/main.css — added settings-section--matrix + all fpm-* table styles
  • templates/pages/settings_metadata_field_priority.html — applied new CSS, Set All row, renamed headers
  • static/js/controllers/field_priority_matrix_controller.js — added setAll() action, fixed controller to use window.Stimulus.Controller (was wrongly using window.StimulusController)
  • static/js/test/field_priority_matrix_controller.test.js — 9 JS unit tests (one assertion per it)
## Matrix UI polish + Set All row — screenshot ![Metadata Field Priority Matrix](https://git.zombor.net/attachments/3a78b0a7-a97e-4234-bace-640609f0c42b) **What's visible:** - Field labels are nowrap — "Published Date", "Series Name", "Series Number", "Series Total" all render on one line - Priority column selects show full provider names: "Hardcover", "Open Library", "Google Books", "— none —" (no truncation) - **Set All:** top row — per-column dropdowns to fan a provider across all fields in that priority column, or "Clear All" to reset to none - Column headers renamed to "1st Priority / 2nd Priority / 3rd Priority / 4th Priority" for clarity - 900px max-width `settings-section--matrix` gives the table room to breathe **Changes:** - `static/css/main.css` — added `settings-section--matrix` + all `fpm-*` table styles - `templates/pages/settings_metadata_field_priority.html` — applied new CSS, Set All row, renamed headers - `static/js/controllers/field_priority_matrix_controller.js` — added `setAll()` action, fixed controller to use `window.Stimulus.Controller` (was wrongly using `window.StimulusController`) - `static/js/test/field_priority_matrix_controller.test.js` — 9 JS unit tests (one assertion per `it`)
Author
Owner

Security RE-Review (bot)

Prior finding 1: AdminRequired on GET + PUT /settings/metadata-field-priority

FIXED. Both routes are wrapped in adminRequired(...) in internal/settings/routes.go. The middleware is injected from d.AdminRequired (declared in internal/appwire/appwire.go) and passed through settings.Wire → RegisterRoutes. E2E tests confirm 401 on unauthenticated GET and PUT. No path bypasses the guard.

One observation: GET /settings/field-priority (the 301 redirect to the new canonical URL) is registered via mux.HandleFunc without adminRequired. This is acceptable — a 301 with no body reveals nothing; the destination /settings/metadata-field-priority is admin-guarded and will enforce auth after the redirect. No information is leaked.

Prior finding 2: X-CSRF-Token missing from matrix PUT fetch

FIXED. field_priority_matrix_controller.js reads document.querySelector('meta[name="csrf-token"]') and sends "X-CSRF-Token": csrf in the fetch headers, matching the pattern used by other controllers.

Re-confirmed clean surface areas

DOM XSS (template rendering): The matrix template (templates/pages/settings_metadata_field_priority.html) uses only html/template {{...}} interpolations. No innerHTML, .html(), template.HTML casts, or template.JS casts appear. Field/provider labels (FieldRow.Label, providerOption.Name) are compile-time string literals from fieldDefs and contentProviders — they never originate from user input. Auto-escaping is complete.

SSRF on cover_url path: The existing cover/download.go SSRF guard (scheme allowlist + private-range IP block) is unchanged by this PR. The matrix cover field stores a provider priority string ("hardcover", "google-books", "open-library") — not a URL — so the matrix write path does not introduce a new cover-URL injection surface.

app_settings write scope: SaveQuickBookMatch uses the fixed key QUICK_BOOK_MATCH. The handler never accepts an arbitrary key from the request body; only the structured MatrixUpdateRequest fields reach UpsertAppSetting.

Field/provider key injection via arbitrary keys: setFieldProvider and setFieldEnabled are exhaustive switch statements over the 16 known field keys. Unknown keys in req.Fields[*].Key fall through to the default no-op — they are silently discarded and cannot mutate unintended settings fields. Provider ID values (P1–P4) are stored as opaque strings in JSON; at resolution time they are compared against provider map keys in MergeMetadataByFieldPriority — an unknown value simply never matches, so arbitrary provider strings are harmless data noise, not an injection path.

Set-All is client-side only: setAll() in the controller manipulates DOM <select> values in the browser; it does not issue any network request. The single server boundary remains the existing save() PUT, which is admin-guarded and CSRF-protected.


REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security RE-Review (bot) ### Prior finding 1: AdminRequired on GET + PUT /settings/metadata-field-priority **FIXED.** Both routes are wrapped in `adminRequired(...)` in `internal/settings/routes.go`. The middleware is injected from `d.AdminRequired` (declared in `internal/appwire/appwire.go`) and passed through `settings.Wire → RegisterRoutes`. E2E tests confirm 401 on unauthenticated GET and PUT. No path bypasses the guard. One observation: `GET /settings/field-priority` (the 301 redirect to the new canonical URL) is registered via `mux.HandleFunc` without `adminRequired`. This is acceptable — a 301 with no body reveals nothing; the destination `/settings/metadata-field-priority` is admin-guarded and will enforce auth after the redirect. No information is leaked. ### Prior finding 2: X-CSRF-Token missing from matrix PUT fetch **FIXED.** `field_priority_matrix_controller.js` reads `document.querySelector('meta[name="csrf-token"]')` and sends `"X-CSRF-Token": csrf` in the fetch headers, matching the pattern used by other controllers. ### Re-confirmed clean surface areas **DOM XSS (template rendering):** The matrix template (`templates/pages/settings_metadata_field_priority.html`) uses only `html/template` `{{...}}` interpolations. No `innerHTML`, `.html()`, `template.HTML` casts, or `template.JS` casts appear. Field/provider labels (`FieldRow.Label`, `providerOption.Name`) are compile-time string literals from `fieldDefs` and `contentProviders` — they never originate from user input. Auto-escaping is complete. **SSRF on cover_url path:** The existing `cover/download.go` SSRF guard (scheme allowlist + private-range IP block) is unchanged by this PR. The matrix `cover` field stores a provider priority string (`"hardcover"`, `"google-books"`, `"open-library"`) — not a URL — so the matrix write path does not introduce a new cover-URL injection surface. **app_settings write scope:** `SaveQuickBookMatch` uses the fixed key `QUICK_BOOK_MATCH`. The handler never accepts an arbitrary key from the request body; only the structured `MatrixUpdateRequest` fields reach `UpsertAppSetting`. **Field/provider key injection via arbitrary keys:** `setFieldProvider` and `setFieldEnabled` are exhaustive `switch` statements over the 16 known field keys. Unknown keys in `req.Fields[*].Key` fall through to the `default` no-op — they are silently discarded and cannot mutate unintended settings fields. Provider ID values (P1–P4) are stored as opaque strings in JSON; at resolution time they are compared against provider map keys in `MergeMetadataByFieldPriority` — an unknown value simply never matches, so arbitrary provider strings are harmless data noise, not an injection path. **Set-All is client-side only:** `setAll()` in the controller manipulates DOM `<select>` values in the browser; it does not issue any network request. The single server boundary remains the existing `save()` PUT, which is admin-guarded and CSRF-protected. --- REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Author
Owner

Code RE-Review (bot)

PR #445 — bookshelf-4y3y.2 re-review (post-fix + new UI work)

CI: green (a84aad7f). Reviewing the full diff against git merge-base origin/main origin/bd-bookshelf-4y3y.2.


Prior Major #1 — Disabled/locked Authors & Categories preserve DB value

FIXED. internal/books/merge.go now routes Authors through mergeSliceField and Categories through mergeCategoriesField. Both functions take fallback []string sourced from existing.Authors / existing.Categories (populated by the new listAuthors/listCategories calls in GetMetadataForRefetch) and return it unchanged when !enabled || locked. Tests at merge_test.go:524–590 (authors disabled→preserve, locked→preserve) and :592–656 (categories disabled→preserve, locked→preserve) confirm the correct semantics.

Prior Major #2 — Old /settings/field-priority URL redirect + rating as matrix row

FIXED. internal/settings/routes.go:12–16 registers a 301 MovedPermanently redirect from GET /settings/field-priority/settings/metadata-field-priority. fieldDefs in matrix_handler.go:99 includes {"rating", "Rating"} as the 16th row. The sidebar link in templates/layouts/base.html:348 was updated to /settings/metadata-field-priority. E2E test at e2e/api/settings_field_priority_test.go:35 verifies the redirect.

Prior Minor #1 — ReplaceMode allowlist validation

FIXED. internal/settings/matrix_handler.go:116–122 validates req.ReplaceMode against validReplaceModes before applying it; unknown values are silently preserved (old value kept). Test at matrix_handler_test.go:400–426 asserts UNKNOWN_MODE leaves ReplaceMode unchanged.

Prior Minor #2 — FieldRow JSON tags

FIXED. matrix_handler.go:32–52: Key, Label, Enabled, P1P4 all have lowercase json:"..." tags.


New UI code review

setAll() / clearAllfield_priority_matrix_controller.js

The controller (static/js/controllers/field_priority_matrix_controller.js:21–34) correctly:

  • Queries only tr[data-field-key] rows from tbodyTarget, so the set-all header row (which has class fpm-set-all-row but no data-field-key) is excluded.
  • Maps "__clear__""" for each field select.
  • Resets the triggering set-all select to "" (back to placeholder) after applying.

No DOM bugs found. Column targeting via selects[col] with an index guard is correct.

JS tests — field_priority_matrix_controller.test.js

9 specs covering: set-all sets target column, applies to all rows, leaves adjacent columns untouched, clears via __clear__, resets dropdown to placeholder; save() reports Saved/Save failed on ok/error; save() collects only data-field-key rows; set-all + save integration. Each it has one expect. Follows project conventions.

Server-rendered Set All row

Template at templates/pages/settings_metadata_field_priority.html:45–88 places the set-all row inside <tbody data-field-priority-matrix-target="tbody">. The row uses class="fpm-set-all-row" with no data-field-key, so querySelectorAll("tr[data-field-key]") in both setAll() and save() correctly excludes it.

CSS — static/css/main.css

All new rules use CSS variables (var(--surface), var(--bg-input), etc.). No inline styles. white-space: nowrap on .fpm-field-label prevents label wrapping. Priority columns are width: 160px. No !important, no hardcoded colors bypassing the theme.

CSP safety

No inline event handlers. All Stimulus wiring via data-action attributes. field_priority_matrix_controller.js is loaded via <script defer src="...?v={{.AssetVersion}}"> in base.html:65. Compliant.


New findings

[MINOR] templates/pages/settings_metadata_field_priority.html:54 — redundant "— none —" in set-all selects
contentProviders (first entry: {ID: "", Name: "— none —"}) is ranged into the set-all dropdowns alongside the explicit <option value="">— set all P1 —</option> placeholder and the <option value="__clear__">Clear All</option>. This produces two value="" options: the placeholder and "— none —". Functionally harmless (both clear), but "— none —" in a "Set All" context is confusing to users — it looks like a provider option but actually clears the column. Fix: use a separate setAllProviders list that omits the {ID: ""} entry (relying on the explicit per-column placeholder instead).

[MINOR] internal/books/metadata_store.go:176–185listAuthors/listCategories errors silently swallowed
When listAuthors or listCategories returns an error, the slice is silently set to nil, which becomes the fallback for the merge engine. If a field is disabled or locked and the DB call for that field's existing value fails, the merge engine will return nil (clearing the data) instead of preserving the real DB value. The test at metadata_store_test.go:248 acknowledges this — it asserts "nil Authors and no error". In practice, the failure window is narrow (main query succeeded, secondary query failed) and covered by CI. Acknowledge in a comment or log the error at Warn per the logging standard trigger ("Write a catch/except → log with context").


REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## Code RE-Review (bot) **PR #445 — bookshelf-4y3y.2 re-review (post-fix + new UI work)** CI: green (a84aad7f). Reviewing the full diff against `git merge-base origin/main origin/bd-bookshelf-4y3y.2`. --- ### Prior Major #1 — Disabled/locked Authors & Categories preserve DB value **FIXED.** `internal/books/merge.go` now routes Authors through `mergeSliceField` and Categories through `mergeCategoriesField`. Both functions take `fallback []string` sourced from `existing.Authors` / `existing.Categories` (populated by the new `listAuthors`/`listCategories` calls in `GetMetadataForRefetch`) and return it unchanged when `!enabled || locked`. Tests at `merge_test.go:524–590` (authors disabled→preserve, locked→preserve) and `:592–656` (categories disabled→preserve, locked→preserve) confirm the correct semantics. ### Prior Major #2 — Old `/settings/field-priority` URL redirect + rating as matrix row **FIXED.** `internal/settings/routes.go:12–16` registers a `301 MovedPermanently` redirect from `GET /settings/field-priority` → `/settings/metadata-field-priority`. `fieldDefs` in `matrix_handler.go:99` includes `{"rating", "Rating"}` as the 16th row. The sidebar link in `templates/layouts/base.html:348` was updated to `/settings/metadata-field-priority`. E2E test at `e2e/api/settings_field_priority_test.go:35` verifies the redirect. ### Prior Minor #1 — ReplaceMode allowlist validation **FIXED.** `internal/settings/matrix_handler.go:116–122` validates `req.ReplaceMode` against `validReplaceModes` before applying it; unknown values are silently preserved (old value kept). Test at `matrix_handler_test.go:400–426` asserts `UNKNOWN_MODE` leaves `ReplaceMode` unchanged. ### Prior Minor #2 — FieldRow JSON tags **FIXED.** `matrix_handler.go:32–52`: `Key`, `Label`, `Enabled`, `P1`–`P4` all have lowercase `json:"..."` tags. --- ### New UI code review #### `setAll()` / `clearAll` — `field_priority_matrix_controller.js` The controller (`static/js/controllers/field_priority_matrix_controller.js:21–34`) correctly: - Queries only `tr[data-field-key]` rows from `tbodyTarget`, so the set-all header row (which has class `fpm-set-all-row` but no `data-field-key`) is excluded. - Maps `"__clear__"` → `""` for each field select. - Resets the triggering set-all select to `""` (back to placeholder) after applying. No DOM bugs found. Column targeting via `selects[col]` with an index guard is correct. #### JS tests — `field_priority_matrix_controller.test.js` 9 specs covering: set-all sets target column, applies to all rows, leaves adjacent columns untouched, clears via `__clear__`, resets dropdown to placeholder; save() reports Saved/Save failed on ok/error; save() collects only `data-field-key` rows; set-all + save integration. Each `it` has one `expect`. Follows project conventions. #### Server-rendered Set All row Template at `templates/pages/settings_metadata_field_priority.html:45–88` places the set-all row inside `<tbody data-field-priority-matrix-target="tbody">`. The row uses `class="fpm-set-all-row"` with no `data-field-key`, so `querySelectorAll("tr[data-field-key]")` in both `setAll()` and `save()` correctly excludes it. #### CSS — `static/css/main.css` All new rules use CSS variables (`var(--surface)`, `var(--bg-input)`, etc.). No inline styles. `white-space: nowrap` on `.fpm-field-label` prevents label wrapping. Priority columns are `width: 160px`. No `!important`, no hardcoded colors bypassing the theme. #### CSP safety No inline event handlers. All Stimulus wiring via `data-action` attributes. `field_priority_matrix_controller.js` is loaded via `<script defer src="...?v={{.AssetVersion}}">` in `base.html:65`. Compliant. --- ### New findings [MINOR] `templates/pages/settings_metadata_field_priority.html:54` — redundant "— none —" in set-all selects `contentProviders` (first entry: `{ID: "", Name: "— none —"}`) is ranged into the set-all dropdowns alongside the explicit `<option value="">— set all P1 —</option>` placeholder and the `<option value="__clear__">Clear All</option>`. This produces two `value=""` options: the placeholder and "— none —". Functionally harmless (both clear), but "— none —" in a "Set All" context is confusing to users — it looks like a provider option but actually clears the column. Fix: use a separate `setAllProviders` list that omits the `{ID: ""}` entry (relying on the explicit per-column placeholder instead). [MINOR] `internal/books/metadata_store.go:176–185` — `listAuthors`/`listCategories` errors silently swallowed When `listAuthors` or `listCategories` returns an error, the slice is silently set to `nil`, which becomes the fallback for the merge engine. If a field is disabled or locked and the DB call for that field's existing value fails, the merge engine will return `nil` (clearing the data) instead of preserving the real DB value. The test at `metadata_store_test.go:248` acknowledges this — it asserts "nil Authors and no error". In practice, the failure window is narrow (main query succeeded, secondary query failed) and covered by CI. Acknowledge in a comment or log the error at `Warn` per the logging standard trigger ("Write a catch/except → log with context"). --- REVIEW VERDICT: 0 blocker, 0 major, 2 minor
zombor force-pushed bd-bookshelf-4y3y.2 from a84aad7f86
All checks were successful
/ JS Unit Tests (pull_request) Successful in 25s
/ Lint (pull_request) Successful in 2m44s
/ Test (pull_request) Successful in 3m23s
/ E2E API (pull_request) Successful in 3m36s
/ E2E Browser (pull_request) Successful in 4m36s
/ Integration (pull_request) Successful in 4m40s
to 260c05a51e
All checks were successful
/ JS Unit Tests (pull_request) Successful in 31s
/ Lint (pull_request) Successful in 2m23s
/ Test (pull_request) Successful in 3m22s
/ Integration (pull_request) Successful in 5m54s
/ E2E Browser (pull_request) Successful in 5m22s
/ E2E API (pull_request) Successful in 10m27s
2026-06-09 02:18:02 +00:00
Compare
zombor merged commit 41742366f0 into main 2026-06-09 02:30:32 +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!445
No description provided.