feat(library): add book-folder paths in New Library modal (bookshelf-cojh) #582

Merged
zombor merged 2 commits from bd-bookshelf-cojh into main 2026-06-16 17:16:01 +00:00
Owner

Summary

  • Users can now add one or more book-folder paths directly in the New Library create modal — no create-then-edit step needed.
  • Paths are optional (zero paths still creates the library).
  • The create modal shows a "Book Folders" section with a client-side add/remove list (no page reload); paths are serialized as hidden <input name="path"> fields and submitted with the form.
  • The create service inserts all paths as library_path rows inside the same transaction as the library row + admin-mapping inserts.

Implementation details

  • Create service: new []string paths parameter; validates each path lexically (validatePath), enforces max-500 count, then inserts rows via txInsertPath inside the transaction.
  • decodeLibraryBody extended with paths []string for JSON bodies; decodePathsBody reads repeated path form fields for HTML; both merged in createHandler.
  • Template (library_modal.html): {{if .IsCreate}} block with #lem-create-path-list + createPathInput target, addCreatePath / removeCreatePath data-actions.
  • Stimulus controller: addCreatePath appends <li> with hidden <input name="path"> + remove button; removeCreatePath removes the row. No server round-trip at this stage.
  • PendingPaths field on modalData re-renders existing paths on validation error.
  • Wire: runInTx extended with txQ.AddLibraryPath.

Test plan

  • Unit: Create with paths persists library_path rows; zero-paths works; invalid path returns ErrValidation; too many paths returns ErrValidation; insertPath failure bubbles up.
  • Unit: createHandler form + JSON path submission passes paths to Create; empty path strings are filtered.
  • 100% coverage gate passes.
  • Browser e2e: open New Library modal → enter name → Add Folder → Create → library has that path (verified via GET /libraries/{id} JSON); screenshot at /tmp/library_create_modal_with_path.png.
  • All 234 e2e specs pass (233 pre-existing + 1 new).

Closes bead bookshelf-cojh on merge.

## Summary - Users can now add one or more book-folder paths directly in the **New Library** create modal — no create-then-edit step needed. - Paths are optional (zero paths still creates the library). - The create modal shows a "Book Folders" section with a client-side add/remove list (no page reload); paths are serialized as hidden `<input name="path">` fields and submitted with the form. - The create service inserts all paths as `library_path` rows inside the same transaction as the library row + admin-mapping inserts. ## Implementation details - `Create` service: new `[]string` paths parameter; validates each path lexically (`validatePath`), enforces max-500 count, then inserts rows via `txInsertPath` inside the transaction. - `decodeLibraryBody` extended with `paths []string` for JSON bodies; `decodePathsBody` reads repeated `path` form fields for HTML; both merged in `createHandler`. - Template (`library_modal.html`): `{{if .IsCreate}}` block with `#lem-create-path-list` + `createPathInput` target, `addCreatePath` / `removeCreatePath` data-actions. - Stimulus controller: `addCreatePath` appends `<li>` with hidden `<input name="path">` + remove button; `removeCreatePath` removes the row. No server round-trip at this stage. - `PendingPaths` field on `modalData` re-renders existing paths on validation error. - Wire: `runInTx` extended with `txQ.AddLibraryPath`. ## Test plan - [x] Unit: `Create` with paths persists `library_path` rows; zero-paths works; invalid path returns ErrValidation; too many paths returns ErrValidation; `insertPath` failure bubbles up. - [x] Unit: `createHandler` form + JSON path submission passes paths to `Create`; empty path strings are filtered. - [x] 100% coverage gate passes. - [x] Browser e2e: open New Library modal → enter name → Add Folder → Create → library has that path (verified via `GET /libraries/{id}` JSON); screenshot at `/tmp/library_create_modal_with_path.png`. - [x] All 234 e2e specs pass (233 pre-existing + 1 new). Closes bead bookshelf-cojh on merge.
feat(library): add book-folder paths in New Library modal (bookshelf-cojh)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 45s
/ Lint (pull_request) Successful in 3m4s
/ Test (pull_request) Successful in 4m50s
/ E2E Browser (pull_request) Successful in 6m13s
/ Integration (pull_request) Successful in 8m15s
/ E2E API (pull_request) Successful in 10m10s
403d8faa48
Users can now add one or more book-folder paths in the New Library
create modal without a separate create-then-edit step.

Changes:
- Create service now accepts []string paths, inserting library_path rows
  in the same transaction as the library + admin-mapping inserts. Zero
  paths remains valid (paths are optional at creation time).
- Paths validated lexically (validatePath) before the transaction; an
  over-length list (>500) is rejected with ErrValidation.
- decodeLibraryBody extended to return paths[] from JSON bodies;
  decodePathsBody reads repeated "path" form fields for HTML forms;
  JSON and form paths are merged in createHandler so both clients work.
- create modal template: adds a "Book Folders" section (visible only
  when IsCreate=true) with a client-side add/remove path list backed by
  hidden <input name="path"> fields that serialize via FormData on submit.
  PendingPaths re-renders existing paths on validation error re-display.
- Stimulus controller: addCreatePath/removeCreatePath methods perform
  client-side DOM manipulation only (no server round-trip); static targets
  createPathList + createPathInput declared.
- All existing handler/service/e2e tests updated; new unit tests cover
  create-with-paths, zero-paths, path validation, too-many-paths, and
  insertPath failure; browser e2e verifies the full flow end-to-end with
  a screenshot and API assertion on the library's paths array.

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

Screenshot: New Library modal with book-folder path added

The create modal now shows a "Book Folders" section. The user typed a path and clicked "Add Folder" — the path appears as a removable row with a hidden input that submits with the form.

New Library modal with path

Verified end-to-end by browser e2e: GET /libraries/{id} after create confirms paths has 1 entry with the expected path value.

## Screenshot: New Library modal with book-folder path added The create modal now shows a "Book Folders" section. The user typed a path and clicked "Add Folder" — the path appears as a removable row with a hidden input that submits with the form. ![New Library modal with path](https://git.zombor.net/attachments/18ded2e8-36af-4358-9ee7-3c06be2e04d3) Verified end-to-end by browser e2e: `GET /libraries/{id}` after create confirms `paths` has 1 entry with the expected path value.
Author
Owner

Security Review — bookshelf-cojh (PR #582)

Head SHA 403d8faa. CI green. Reviewed per .claude/rules/review-standard.md.


Findings

[MAJOR] internal/library/service.go:193 — Create skips the stat/IsDir check that AddPath enforces

AddPath (line 325–327) calls stat(path) and rejects anything that isn't a readable directory. Create only calls validatePath() (lexical checks: empty, null bytes, length ≤ 4096, absolute, no ..), so a caller can attach a path that does not exist or is not a directory at library-creation time. The immediate impact is modest (the scan will fail later when it tries to walk the path), but it's a behavioural divergence from the edit add-path flow: the same invariant is meant to apply at both entry points and this PR was specifically tasked to confirm parity. The lexical guard (validatePath) already eliminates traversal and injection; the missing piece is the existence/isDir check.

Suggested fix: inject stat func(string) (os.FileInfo, error) as a dep into Create (matching the AddPath signature) and call it inside the path-validation loop before the transaction begins, returning middleware.ErrValidation on failure. The test suite already stubs insertPathFn; a parallel stub for stat keeps the unit tests free of real I/O.


Confirmed clean

  • Auth: POST /libraries is behind g.ManipulateLibrary — unchanged.
  • SQL injection: txInsertPath uses sqlc.AddLibraryPathParams typed params — fully parameterized.
  • Path traversal / null bytes: validatePath checks empty, null bytes, length ≤ 4096, absolute, .. segments — identical to the edit flow's lexical checks.
  • Count bound: maxCreatePaths = 500 checked before any DB work.
  • XSS (template): {{.}} inside value= and aria-label= rendered via html/template — auto-escaped.
  • XSS (JS): code.textContent = path and hidden.value = path are property assignments, not innerHTML. Safe.
  • No new Grimmory columns: uses existing library_path table / AddLibraryPath query.
  • Ownership: library ID is generated server-side on insert; paths are attached to that ID — no user-controlled library ID for path attachment.

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

## Security Review — bookshelf-cojh (PR #582) _Head SHA 403d8faa. CI green. Reviewed per `.claude/rules/review-standard.md`._ --- ### Findings **[MAJOR] internal/library/service.go:193 — `Create` skips the `stat`/`IsDir` check that `AddPath` enforces** `AddPath` (line 325–327) calls `stat(path)` and rejects anything that isn't a readable directory. `Create` only calls `validatePath()` (lexical checks: empty, null bytes, length ≤ 4096, absolute, no `..`), so a caller can attach a path that does not exist or is not a directory at library-creation time. The immediate impact is modest (the scan will fail later when it tries to walk the path), but it's a behavioural divergence from the edit add-path flow: the same invariant is meant to apply at both entry points and this PR was specifically tasked to confirm parity. The lexical guard (`validatePath`) already eliminates traversal and injection; the missing piece is the existence/isDir check. Suggested fix: inject `stat func(string) (os.FileInfo, error)` as a dep into `Create` (matching the `AddPath` signature) and call it inside the path-validation loop before the transaction begins, returning `middleware.ErrValidation` on failure. The test suite already stubs `insertPathFn`; a parallel stub for `stat` keeps the unit tests free of real I/O. --- ### Confirmed clean - **Auth**: `POST /libraries` is behind `g.ManipulateLibrary` — unchanged. - **SQL injection**: `txInsertPath` uses `sqlc.AddLibraryPathParams` typed params — fully parameterized. - **Path traversal / null bytes**: `validatePath` checks empty, null bytes, length ≤ 4096, absolute, `..` segments — identical to the edit flow's lexical checks. - **Count bound**: `maxCreatePaths = 500` checked before any DB work. - **XSS (template)**: `{{.}}` inside `value=` and `aria-label=` rendered via `html/template` — auto-escaped. - **XSS (JS)**: `code.textContent = path` and `hidden.value = path` are property assignments, not `innerHTML`. Safe. - **No new Grimmory columns**: uses existing `library_path` table / `AddLibraryPath` query. - **Ownership**: library ID is generated server-side on insert; paths are attached to that ID — no user-controlled library ID for path attachment. --- REVIEW VERDICT: 0 blocker, 1 major, 0 minor
Author
Owner

Security Re-Review — Pass 3 (SHA 60ab9641, PR #567, bead bookshelf-c15a.2)

Prior findings — resolution status

[MAJOR] Cross-criterion cursor namespace collision — RESOLVED.
The new ScanCursor.Positions map[string]CriterionPos gives each active criterion its own (afterKey, afterID). The handler reads userLibraryIDs and builds scopedIDs before the cursor is decoded, so the cursor has no influence on the library scope. Unknown keys in Positions (attacker-injected) are silently ignored via map-lookup — they cannot widen scope or cause a panic. The afterKey/afterID values are passed only as SQL parameters in a keyset WHERE clause; the library-ID filter (WHERE b.library_id IN (?)) is applied independently in every finder. Cross-user read via cursor is not possible.

[MINOR] No http.MaxBytesReader — RESOLVED.
http.MaxBytesReader(w, r.Body, 4096) is applied in ScanHandler before parseScanRequest is called (handler.go:128-130). The error-mapper handles *http.MaxBytesError via errors.As (checked before errors.Is(ErrValidation) in the switch), so an oversized body returns 413 not 400. The handler unit test at handler_test.go:339-344 confirms 413 is returned.


Fresh threat-model

SQL injection — all five finder queries (FindDuplicatesByISBN, FindDuplicatesByExternalID, FindDuplicatesByTitleAuthor, FindDuplicatesByDirectory, FindDuplicatesByFilename) pass every user-influenced value (cursor afterKey/afterID, limit+1) as positional ? parameters. Library IDs are server-computed from the session and passed the same way. The LIKE patterns use %%/%% (Go fmt.Sprintf escaping to %/%) which are string literals, not user-controlled. No injection surface found.

ExternalID UNION ALL arg binding — manually verified: HC branch gets libIn, afterKey, afterKey, afterID, limit+1, libIn; GR branch gets the same pattern; total args = 4N + 8 matching the four %s expansions + eight cursor/limit ?s in the SQL. Both branches are independently scoped by userLibraryIDs.

Resource/DoS — request body capped at 4096 bytes via MaxBytesReader. The cursor query param is bounded by Go's default MaxHeaderBytes (1 MB) and is a server-generated opaque blob with no unbounded sub-structure. Each finder's inner subquery limits to limit+1 groups (clamped to max 200 by clampLimit); the ExternalID combined outer query has no second LIMIT but is bounded by 2x(limit+1) groups, which is documented and handled correctly by the service-layer seen-map. No unbounded scan path found.

Cursor decode safetyDecodeCursor returns a typed error (wrapping ErrValidation) on invalid base64 or malformed JSON; no panic path. The Positions map is deserialized into a Go struct with typed fields; oversized maps are bounded by the cursor string length, itself bounded by HTTP headers.

Multi-user fail-closed — every finder has if len(userLibraryIDs) == 0 { return nil, nil } as the first guard. filterToLibrary rejects requests for a library not in the user's accessible set (returns 404). userLibraryIDs is derived from the authenticated session, never from the request body or cursor.

keptKeys pagination correctness — the seen2 loop correctly breaks on len(seen2) > limit, excluding the peek group from keptKeys. The lastPos update only iterates labeledRows in keptKeys, so the cursor points to the last book of the last returned group, not the discarded peek group.


New findings

No new blockers or majors. One minor style note:

[MINOR] internal/dedup/handler.go:53 — double-wrapped error format %w: %w with ErrValidation first
fmt.Errorf("dedup scan: decode body: %w: %w", middleware.ErrValidation, err) is an unusual multi-error pattern. The statusFor switch checks errors.As(err, &maxBytesErr) before errors.Is(ErrValidation), so the 413 path wins correctly and the test confirms it. The double-colon separator is cosmetic and non-standard but causes no correctness or security issue. The same pattern exists in the merge handler, suggesting it is an established codebase convention. No fix required.


REVIEW VERDICT: 0 blocker, 0 major, 1 minor

## Security Re-Review — Pass 3 (SHA 60ab9641, PR #567, bead bookshelf-c15a.2) ### Prior findings — resolution status **[MAJOR] Cross-criterion cursor namespace collision — RESOLVED.** The new `ScanCursor.Positions map[string]CriterionPos` gives each active criterion its own `(afterKey, afterID)`. The handler reads `userLibraryIDs` and builds `scopedIDs` *before* the cursor is decoded, so the cursor has no influence on the library scope. Unknown keys in `Positions` (attacker-injected) are silently ignored via map-lookup — they cannot widen scope or cause a panic. The `afterKey`/`afterID` values are passed only as SQL *parameters* in a keyset WHERE clause; the library-ID filter (`WHERE b.library_id IN (?)`) is applied independently in every finder. Cross-user read via cursor is not possible. **[MINOR] No `http.MaxBytesReader` — RESOLVED.** `http.MaxBytesReader(w, r.Body, 4096)` is applied in `ScanHandler` before `parseScanRequest` is called (`handler.go:128-130`). The error-mapper handles `*http.MaxBytesError` via `errors.As` (checked before `errors.Is(ErrValidation)` in the switch), so an oversized body returns 413 not 400. The handler unit test at `handler_test.go:339-344` confirms 413 is returned. --- ### Fresh threat-model **SQL injection** — all five finder queries (`FindDuplicatesByISBN`, `FindDuplicatesByExternalID`, `FindDuplicatesByTitleAuthor`, `FindDuplicatesByDirectory`, `FindDuplicatesByFilename`) pass every user-influenced value (cursor `afterKey`/`afterID`, `limit+1`) as positional `?` parameters. Library IDs are server-computed from the session and passed the same way. The LIKE patterns use `%%/%%` (Go `fmt.Sprintf` escaping to `%/%`) which are string literals, not user-controlled. No injection surface found. **ExternalID UNION ALL arg binding** — manually verified: HC branch gets `libIn, afterKey, afterKey, afterID, limit+1, libIn`; GR branch gets the same pattern; total args = `4N + 8` matching the four `%s` expansions + eight cursor/limit `?`s in the SQL. Both branches are independently scoped by `userLibraryIDs`. **Resource/DoS** — request body capped at 4096 bytes via `MaxBytesReader`. The cursor query param is bounded by Go's default `MaxHeaderBytes` (1 MB) and is a server-generated opaque blob with no unbounded sub-structure. Each finder's inner subquery limits to `limit+1` groups (clamped to max 200 by `clampLimit`); the ExternalID combined outer query has no second LIMIT but is bounded by 2x(limit+1) groups, which is documented and handled correctly by the service-layer seen-map. No unbounded scan path found. **Cursor decode safety** — `DecodeCursor` returns a typed error (wrapping `ErrValidation`) on invalid base64 or malformed JSON; no panic path. The `Positions` map is deserialized into a Go struct with typed fields; oversized maps are bounded by the cursor string length, itself bounded by HTTP headers. **Multi-user fail-closed** — every finder has `if len(userLibraryIDs) == 0 { return nil, nil }` as the first guard. `filterToLibrary` rejects requests for a library not in the user's accessible set (returns 404). `userLibraryIDs` is derived from the authenticated session, never from the request body or cursor. **`keptKeys` pagination correctness** — the seen2 loop correctly breaks on `len(seen2) > limit`, excluding the peek group from `keptKeys`. The `lastPos` update only iterates `labeledRows` in `keptKeys`, so the cursor points to the last book of the last *returned* group, not the discarded peek group. --- ### New findings No new blockers or majors. One minor style note: [MINOR] internal/dedup/handler.go:53 — double-wrapped error format `%w: %w` with ErrValidation first `fmt.Errorf("dedup scan: decode body: %w: %w", middleware.ErrValidation, err)` is an unusual multi-error pattern. The `statusFor` switch checks `errors.As(err, &maxBytesErr)` before `errors.Is(ErrValidation)`, so the 413 path wins correctly and the test confirms it. The double-colon separator is cosmetic and non-standard but causes no correctness or security issue. The same pattern exists in the merge handler, suggesting it is an established codebase convention. No fix required. --- REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Author
Owner

Code Review — bookshelf-q9sg (PR #548)

Phase 0: DEMO Verification

No DEMO block applies — this is a test-only coverage fix; the coverage gate is verified by CI (green).

Phase 1: Spec Compliance

Single file changed: internal/stats/handler_internal_test.go. The bead requires a test exercising the day.After(today) break at handler.go:200-201. The diff delivers exactly that and nothing else. No scope creep.

Phase 2: Code Quality

Weekday arithmetic verified independently:

  • time.Date(2024, 6, 12, ...) — 2024-06-12 is Wednesday (Go time.Weekday() = 3, where Sunday=0).
  • That weeks Sunday = 2024-06-09. Cells[4] = Thursday 2024-06-13, which is after today (2024-06-12).
  • The break at handler.go:200 fires before d=4, leaving Cells[4] nil.
  • Expect(last.Cells[4]).To(BeNil()) directly asserts this. Not a tautology.

Determinism: now is a fixed time.Date(2024, 6, 12, 12, 0, 0, 0, time.UTC) — no time.Now(). Coverage is date-independent; will not flake.

Ginkgo conventions:

  • var (weeks []heatmapWeek) declared at top of Describe block.
  • Invocation in JustBeforeEach, assertion in It.
  • One Expect per It.
  • now declared inside JustBeforeEach (not a varying dep, no BeforeEach needed). Acceptable.

Coverage gate: scripts/check-coverage.sh unchanged. No exclusions added.

No findings.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Code Review — bookshelf-q9sg (PR #548) ### Phase 0: DEMO Verification No DEMO block applies — this is a test-only coverage fix; the coverage gate is verified by CI (green). ### Phase 1: Spec Compliance Single file changed: `internal/stats/handler_internal_test.go`. The bead requires a test exercising the `day.After(today)` break at `handler.go:200-201`. The diff delivers exactly that and nothing else. No scope creep. ### Phase 2: Code Quality **Weekday arithmetic verified independently:** - `time.Date(2024, 6, 12, ...)` — 2024-06-12 is Wednesday (Go `time.Weekday()` = 3, where Sunday=0). - That weeks Sunday = 2024-06-09. `Cells[4]` = Thursday 2024-06-13, which is after `today` (2024-06-12). - The break at `handler.go:200` fires before `d=4`, leaving `Cells[4]` nil. - `Expect(last.Cells[4]).To(BeNil())` directly asserts this. Not a tautology. **Determinism:** `now` is a fixed `time.Date(2024, 6, 12, 12, 0, 0, 0, time.UTC)` — no `time.Now()`. Coverage is date-independent; will not flake. **Ginkgo conventions:** - `var (weeks []heatmapWeek)` declared at top of `Describe` block. - Invocation in `JustBeforeEach`, assertion in `It`. - One `Expect` per `It`. - `now` declared inside `JustBeforeEach` (not a varying dep, no `BeforeEach` needed). Acceptable. **Coverage gate:** `scripts/check-coverage.sh` unchanged. No exclusions added. No findings. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Author
Owner

Code Review — bookshelf-cojh (PR #582)

Phase 0: DEMO Verification

No explicit DEMO block in the bead. The browser e2e test at e2e/browser/library_modal_test.go:318 (New Library modal: add book-folder path at creation) is the functional proof: it navigates to /books, opens the create modal, types a path, clicks Add Folder, submits the form, then verifies via the API that the library was created with the path without any edit step. CI is green. Screenshot posted as comment 6851. Phase 0 PASS.


Phase 1: Spec Compliance

All five spec requirements are implemented:

  1. Book Folders section in create modal (template, controller targets + methods)
  2. Create service persists paths in-tx (service.go, wire.go)
  3. Zero paths still works
  4. Path validation + count bound reuse maxLibraryPaths
  5. Service, handler, and browser e2e tests present

Missing: the bead spec explicitly requires a JS unit test for add/remove rows in the create modal. addCreatePath and removeCreatePath were added to library_edit_modal_controller.js but static/js/test/library_edit_modal_controller.test.js was not modified.


Phase 2: Code Quality

[MAJOR] static/js/test/library_edit_modal_controller.test.js — JS unit tests for addCreatePath/removeCreatePath absent
The bead spec says "JS unit for add/remove rows in the create modal". The two new methods (addCreatePath, removeCreatePath) added to library_edit_modal_controller.js:120-163 are not covered by the existing JS test file, which was left unchanged (confirmed by git diff producing no output). The browser e2e catches end-to-end submission but DOM-manipulation bugs in the controller (wrong target selector, hidden-input name, li removal logic) would not surface in CI without a unit test. Every other Stimulus controller with non-trivial DOM manipulation has a matching .test.js. This is a spec gap and a convention violation.

[MINOR] e2e/browser/library_modal_test.go:362 — multiple Expect calls inside one It block
The single browser It block contains 11 Expect calls across two HTTP round-trips plus decoding. Project convention requires exactly one Expect per It. A follow-up split into focused It blocks (one for status, one for path count, one for path value) would be cleaner.

[MINOR] internal/library/handler.go:154-156 — form paths unconditionally decoded then overwritten for JSON
decodePathsBody(r) is called for every POST /libraries request including JSON, then immediately discarded for JSON (it returns nil due to its own Content-Type guard). No bug, but confusing: paths = decodePathsBody(r) always runs, then if len(jsonPaths) > 0 { paths = jsonPaths } silently overlays it. An explicit else-branch makes the mutual-exclusion clear.


REVIEW VERDICT: 0 blocker, 1 major, 2 minor

## Code Review — bookshelf-cojh (PR #582) ### Phase 0: DEMO Verification No explicit DEMO block in the bead. The browser e2e test at `e2e/browser/library_modal_test.go:318` (New Library modal: add book-folder path at creation) is the functional proof: it navigates to /books, opens the create modal, types a path, clicks Add Folder, submits the form, then verifies via the API that the library was created with the path without any edit step. CI is green. Screenshot posted as comment 6851. Phase 0 PASS. --- ### Phase 1: Spec Compliance All five spec requirements are implemented: 1. Book Folders section in create modal (template, controller targets + methods) 2. Create service persists paths in-tx (service.go, wire.go) 3. Zero paths still works 4. Path validation + count bound reuse maxLibraryPaths 5. Service, handler, and browser e2e tests present **Missing:** the bead spec explicitly requires a **JS unit test for add/remove rows in the create modal**. `addCreatePath` and `removeCreatePath` were added to `library_edit_modal_controller.js` but `static/js/test/library_edit_modal_controller.test.js` was not modified. --- ### Phase 2: Code Quality [MAJOR] static/js/test/library_edit_modal_controller.test.js — JS unit tests for addCreatePath/removeCreatePath absent The bead spec says "JS unit for add/remove rows in the create modal". The two new methods (addCreatePath, removeCreatePath) added to library_edit_modal_controller.js:120-163 are not covered by the existing JS test file, which was left unchanged (confirmed by git diff producing no output). The browser e2e catches end-to-end submission but DOM-manipulation bugs in the controller (wrong target selector, hidden-input name, li removal logic) would not surface in CI without a unit test. Every other Stimulus controller with non-trivial DOM manipulation has a matching .test.js. This is a spec gap and a convention violation. [MINOR] e2e/browser/library_modal_test.go:362 — multiple Expect calls inside one It block The single browser It block contains 11 Expect calls across two HTTP round-trips plus decoding. Project convention requires exactly one Expect per It. A follow-up split into focused It blocks (one for status, one for path count, one for path value) would be cleaner. [MINOR] internal/library/handler.go:154-156 — form paths unconditionally decoded then overwritten for JSON decodePathsBody(r) is called for every POST /libraries request including JSON, then immediately discarded for JSON (it returns nil due to its own Content-Type guard). No bug, but confusing: paths = decodePathsBody(r) always runs, then if len(jsonPaths) > 0 { paths = jsonPaths } silently overlays it. An explicit else-branch makes the mutual-exclusion clear. --- REVIEW VERDICT: 0 blocker, 1 major, 2 minor
zombor force-pushed bd-bookshelf-cojh from 403d8faa48
All checks were successful
/ JS Unit Tests (pull_request) Successful in 45s
/ Lint (pull_request) Successful in 3m4s
/ Test (pull_request) Successful in 4m50s
/ E2E Browser (pull_request) Successful in 6m13s
/ Integration (pull_request) Successful in 8m15s
/ E2E API (pull_request) Successful in 10m10s
to a79ef4de13
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m10s
/ Test (pull_request) Successful in 2m57s
/ E2E API (pull_request) Successful in 4m38s
/ Integration (pull_request) Successful in 5m3s
/ Lint (pull_request) Has been cancelled
/ E2E Browser (pull_request) Has been cancelled
2026-06-16 16:56:58 +00:00
Compare
fix(library): address PR #582 review findings (bookshelf-cojh)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 35s
/ Lint (pull_request) Successful in 2m14s
/ Test (pull_request) Successful in 3m27s
/ E2E Browser (pull_request) Successful in 4m39s
/ E2E API (pull_request) Successful in 5m30s
/ Integration (pull_request) Successful in 5m40s
2bf8216eeb
- Create now accepts stat dep and validates each path exists + is a
  directory, matching AddPath parity (security MAJOR)
- wire.go passes os.Stat to Create; all service_test.go callers updated
- Add service_test.go cases: non-existent path → ErrValidation,
  non-directory path → ErrValidation
- Add JS unit tests for addCreatePath / removeCreatePath (MAJOR):
  appends row with correct hidden-input name, displays path text,
  ignores empty/whitespace, handles multiple paths, removes correct li
- Split e2e It with 11 Expects into 3 It blocks sharing BeforeEach (MINOR)
- Restructure createHandler decodePathsBody call for readability (MINOR)

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

Review findings addressed in commit 2bf8216eeb:

[MAJOR] security — Create stat/IsDir parity with AddPath

  • internal/library/service.go: Create now accepts stat func(string) (os.FileInfo, error) as first curried dep; iterates supplied paths and calls stat after lexical validation, rejecting non-existent or non-directory paths with the same error as AddPath (line ~210-214).
  • internal/library/wire.go: wired with os.Stat.
  • internal/library/service_test.go: two new Context blocks — non-existent path → ErrValidation, regular-file path → ErrValidation; all existing callers updated to new 3-arg signature.

[MAJOR] code — JS unit tests for addCreatePath / removeCreatePath

  • static/js/test/library_edit_modal_controller.test.js: added mountCreateModal helper and 5 new tests in ADD CREATE PATH describe block:
    • appends row with input[type=hidden][name=path]
    • displays path text in .lem-path-text
    • empty/whitespace input is ignored (no li appended)
    • multiple paths create separate rows
    • removeCreatePath removes the correct li via closest('li')

[MINOR] e2e one-Expect per It

  • e2e/browser/library_modal_test.go: split the single It with 11 Expects into a Describe with shared BeforeEach (browser interaction + API round-trips) and 3 separate It blocks: library appears in list, exactly one path, correct path value.

[MINOR] handler readability

  • internal/library/handler.go:154-156: restructured to if len(jsonPaths) > 0 { paths = jsonPaths } else { paths = decodePathsBody(r) } — no unconditional call followed by overwrite.
Review findings addressed in commit 2bf8216eebe53c980a15b37a4ed205bf230ca9a0: **[MAJOR] security — Create stat/IsDir parity with AddPath** - `internal/library/service.go`: `Create` now accepts `stat func(string) (os.FileInfo, error)` as first curried dep; iterates supplied paths and calls stat after lexical validation, rejecting non-existent or non-directory paths with the same error as `AddPath` (line ~210-214). - `internal/library/wire.go`: wired with `os.Stat`. - `internal/library/service_test.go`: two new `Context` blocks — non-existent path → `ErrValidation`, regular-file path → `ErrValidation`; all existing callers updated to new 3-arg signature. **[MAJOR] code — JS unit tests for addCreatePath / removeCreatePath** - `static/js/test/library_edit_modal_controller.test.js`: added `mountCreateModal` helper and 5 new tests in `ADD CREATE PATH` describe block: - appends row with `input[type=hidden][name=path]` - displays path text in `.lem-path-text` - empty/whitespace input is ignored (no `li` appended) - multiple paths create separate rows - `removeCreatePath` removes the correct `li` via `closest('li')` **[MINOR] e2e one-Expect per It** - `e2e/browser/library_modal_test.go`: split the single `It` with 11 Expects into a `Describe` with shared `BeforeEach` (browser interaction + API round-trips) and 3 separate `It` blocks: library appears in list, exactly one path, correct path value. **[MINOR] handler readability** - `internal/library/handler.go:154-156`: restructured to `if len(jsonPaths) > 0 { paths = jsonPaths } else { paths = decodePathsBody(r) }` — no unconditional call followed by overwrite.
Author
Owner

Security Re-Review — bookshelf-cojh (PR #582, SHA 2bf8216e)

Scope: re-verify the [MAJOR] from comment 6854 (Create lacked stat/IsDir check); confirm no remaining create-vs-edit validation divergence; audit all other surfaces.


Primary finding (comment 6854) — RESOLVED

[MAJOR] Create skipped the stat/IsDir checkFIXED.

internal/library/service.go Create now:

  1. Injects stat func(string) (os.FileInfo, error) as its first curried argument (line ~174).
  2. Before the transaction, iterates every supplied path: calls validatePath(p) (null bytes / length / absolute / no-.. segments) then stat(p) and checks !info.IsDir() — identical logic to AddPath (lines ~200-207).
  3. Rejects with middleware.ErrValidation on any failure, same error type as AddPath.

internal/library/wire.go passes os.Stat at construction: Create(os.Stat, runInTx, ...) — parity with AddPath(os.Stat, ...).

Create-vs-AddPath divergence: none remaining.

Check AddPath Create (after fix)
validatePath (null / length / absolute / no-..)
stat existence + IsDir
Count bound (maxCreatePaths=500) n/a
All checks before transaction

Remaining surfaces — all clear

Parameterized library_path insert: txInsertPath receives sqlc.AddLibraryPathParams with typed sql.NullString — no string interpolation into SQL.

validatePath (null bytes / length / absolute / no-..): called on every path in both Create and AddPath. The . segment is not explicitly rejected, but filepath.IsAbs + ".." segment check covers the traversal cases; a bare . in a path component doesn't escape an absolute root prefix and is harmless.

Count bound: maxCreatePaths = maxLibraryPaths = 500 enforced before the transaction, before stat calls — correct ordering prevents a slow-path DoS on an oversized batch.

Auth/ownership: unchanged — d.AdminRequired / ManipulateLibrary gate remain on the create route.

XSS — PendingPaths template rendering: all three emission points in templates/pages/library_modal.html (lines ~76–78) use {{.}} inside html/template — Go's html/template auto-escapes for HTML context (<code>), attribute context (value="{{.}}", aria-label="Remove path {{.}}"). No unescaped {{- . -}} or template.HTML casts. Safe.

No new Grimmory columns: diff touches only Go service/handler/wire/test/JS/template files — no .sql migration or sqlc queries modified.


Minor observations (no security impact)

[MINOR] internal/library/handler.go:155 — PendingPaths only set on form path

On a JSON POST /libraries that fails validation, PendingPaths is populated correctly (it is set regardless of content-type). No issue; just noting the comment in decodeLibraryBody is accurate. No action needed.

[MINOR] internal/library/handler.go:154-158 — decodePathsBody returns nil for JSON, then jsonPaths branch is taken

The if len(jsonPaths) > 0 { paths = jsonPaths } else { paths = decodePathsBody(r) } correctly handles both content types. The else branch correctly returns nil for a JSON body (guarded inside decodePathsBody). Logic is sound.


REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## Security Re-Review — bookshelf-cojh (PR #582, SHA 2bf8216e) _Scope: re-verify the [MAJOR] from comment 6854 (Create lacked stat/IsDir check); confirm no remaining create-vs-edit validation divergence; audit all other surfaces._ --- ### Primary finding (comment 6854) — RESOLVED **[MAJOR] Create skipped the stat/IsDir check** — **FIXED.** `internal/library/service.go` `Create` now: 1. Injects `stat func(string) (os.FileInfo, error)` as its first curried argument (line ~174). 2. Before the transaction, iterates every supplied path: calls `validatePath(p)` (null bytes / length / absolute / no-`..` segments) then `stat(p)` and checks `!info.IsDir()` — identical logic to `AddPath` (lines ~200-207). 3. Rejects with `middleware.ErrValidation` on any failure, same error type as `AddPath`. `internal/library/wire.go` passes `os.Stat` at construction: `Create(os.Stat, runInTx, ...)` — parity with `AddPath(os.Stat, ...)`. **Create-vs-AddPath divergence: none remaining.** | Check | AddPath | Create (after fix) | |---|---|---| | `validatePath` (null / length / absolute / no-..) | ✓ | ✓ | | `stat` existence + `IsDir` | ✓ | ✓ | | Count bound (maxCreatePaths=500) | n/a | ✓ | | All checks before transaction | ✓ | ✓ | --- ### Remaining surfaces — all clear **Parameterized `library_path` insert:** `txInsertPath` receives `sqlc.AddLibraryPathParams` with typed `sql.NullString` — no string interpolation into SQL. **`validatePath` (null bytes / length / absolute / no-..):** called on every path in both `Create` and `AddPath`. The `.` segment is not explicitly rejected, but `filepath.IsAbs` + `".."` segment check covers the traversal cases; a bare `.` in a path component doesn't escape an absolute root prefix and is harmless. **Count bound:** `maxCreatePaths = maxLibraryPaths = 500` enforced before the transaction, before stat calls — correct ordering prevents a slow-path DoS on an oversized batch. **Auth/ownership:** unchanged — `d.AdminRequired` / `ManipulateLibrary` gate remain on the create route. **XSS — `PendingPaths` template rendering:** all three emission points in `templates/pages/library_modal.html` (lines ~76–78) use `{{.}}` inside `html/template` — Go's `html/template` auto-escapes for HTML context (`<code>`), attribute context (`value="{{.}}"`, `aria-label="Remove path {{.}}"`). No unescaped `{{- . -}}` or `template.HTML` casts. Safe. **No new Grimmory columns:** diff touches only Go service/handler/wire/test/JS/template files — no `.sql` migration or `sqlc` queries modified. --- ### Minor observations (no security impact) **[MINOR] `internal/library/handler.go`:155 — `PendingPaths` only set on form path** On a JSON `POST /libraries` that fails validation, `PendingPaths` is populated correctly (it is set regardless of content-type). No issue; just noting the comment in `decodeLibraryBody` is accurate. No action needed. **[MINOR] `internal/library/handler.go`:154-158 — `decodePathsBody` returns `nil` for JSON, then `jsonPaths` branch is taken** The `if len(jsonPaths) > 0 { paths = jsonPaths } else { paths = decodePathsBody(r) }` correctly handles both content types. The else branch correctly returns nil for a JSON body (guarded inside `decodePathsBody`). Logic is sound. --- REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author
Owner

CODE REVIEW (RE-REVIEW): APPROVED

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

Prior Finding Verification

MAJOR 1: ExternalID OR-dup loss — RESOLVED

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

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

MAJOR 2: Directory/Filename correlated-subquery — RESOLVED

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

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

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

All three are positive tests. Finding is resolved.

MINOR: Browser checkbox MustEval bool read — RESOLVED

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

MINOR: Stray double blank line — RESOLVED

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

Fresh-Eyes Pass

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

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

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

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

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

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

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

CODE RE-REVIEW: APPROVED — bookshelf-cojh (PR #582, SHA 2bf8216e)

Fix Verification

[MAJOR code] JS unit tests for addCreatePath/removeCreatePath

VERIFIED. static/js/test/library_edit_modal_controller.test.js now contains a full describe("ADD CREATE PATH") block with 5 non-vacuous tests:

  • addCreatePath appends a path row with the correct hidden-input name — asserts hidden.name === "path"
  • addCreatePath appends a path row displaying the entered path — asserts .lem-path-text textContent equals the input value
  • addCreatePath with empty input does nothing — asserts whitespace-only input leaves 0 li rows
  • addCreatePath appends multiple paths as separate rows — adds two paths, asserts 2 li rows
  • removeCreatePath removes the correct li row — adds 2 paths, removes the first, asserts 1 row remains with the second path text

All tests exercise real controller logic via the Stimulus test harness. Non-vacuous and complete.

[MAJOR security] service.go Create stat-validates each path before tx

VERIFIED. internal/library/service.go Create now accepts stat func(string) (os.FileInfo, error) as its first dependency (line 173), calls validatePath(p) then stat(p) for each supplied path before opening the transaction, and wraps any stat error or non-directory result in middleware.ErrValidation — exact parity with AddPath.

internal/library/wire.go passes os.Stat at line 49.

Unit tests in service_test.go cover:

  • missing path (stubMissingStat -> statErr != nil) -> ErrValidation
  • regular file (stubFileStat -> !info.IsDir()) -> ErrValidation
  • non-absolute path (relative/path) -> ErrValidation from validatePath
  • too many paths (501) -> ErrValidation
  • insertPath DB failure -> error propagated
  • zero paths at create -> no path rows inserted
  • two paths at create -> two path rows with correct values and library ID

[MINOR] e2e 11-Expect It split

VERIFIED. The new e2e Describe uses a shared BeforeEach (all setup + HTTP calls) with exactly 3 It blocks, each containing exactly one Expect.

[MINOR] handler.go decodePathsBody if/else

VERIFIED. createHandler at lines 154-158 uses a clean if/else branch. decodePathsBody also uses an early-return guard for JSON content type.

Additional Observation

[MINOR] e2e/browser/library_modal_test.go — nil-panic risk in third It block

The It("persists the correct path value") block indexes libPaths[0] unconditionally. libPaths is only populated inside "if newLibID != 0" in BeforeEach. If the library was not found (newLibID stays 0), libPaths remains nil and libPaths[0] panics rather than producing a clean Ginkgo failure. Ginkgo runs It blocks independently, so the third can execute and panic even when the second already failed. In a green-path e2e this never fires. Minor, does not block.

Compatibility checks

  • Zero-paths-create: paths is nil when no paths submitted; the inner tx loop is a no-op — create-only still works.
  • No new Grimmory columns: only internal/library/*{.go,_test.go}, JS controller + tests, template, and e2e test changed. No migration files.
  • Conventions: stat injected as function dep (curried pattern), os.Stat passed at wire time, service tests use closure stubs (stubDirStat/stubMissingStat/stubFileStat). Consistent with project conventions.

REVIEW VERDICT: 0 blocker, 0 major, 1 minor

CODE RE-REVIEW: APPROVED — bookshelf-cojh (PR #582, SHA 2bf8216e) ## Fix Verification ### [MAJOR code] JS unit tests for addCreatePath/removeCreatePath VERIFIED. static/js/test/library_edit_modal_controller.test.js now contains a full describe("ADD CREATE PATH") block with 5 non-vacuous tests: - addCreatePath appends a path row with the correct hidden-input name — asserts hidden.name === "path" - addCreatePath appends a path row displaying the entered path — asserts .lem-path-text textContent equals the input value - addCreatePath with empty input does nothing — asserts whitespace-only input leaves 0 li rows - addCreatePath appends multiple paths as separate rows — adds two paths, asserts 2 li rows - removeCreatePath removes the correct li row — adds 2 paths, removes the first, asserts 1 row remains with the second path text All tests exercise real controller logic via the Stimulus test harness. Non-vacuous and complete. ### [MAJOR security] service.go Create stat-validates each path before tx VERIFIED. internal/library/service.go Create now accepts stat func(string) (os.FileInfo, error) as its first dependency (line 173), calls validatePath(p) then stat(p) for each supplied path before opening the transaction, and wraps any stat error or non-directory result in middleware.ErrValidation — exact parity with AddPath. internal/library/wire.go passes os.Stat at line 49. Unit tests in service_test.go cover: - missing path (stubMissingStat -> statErr != nil) -> ErrValidation - regular file (stubFileStat -> !info.IsDir()) -> ErrValidation - non-absolute path (relative/path) -> ErrValidation from validatePath - too many paths (501) -> ErrValidation - insertPath DB failure -> error propagated - zero paths at create -> no path rows inserted - two paths at create -> two path rows with correct values and library ID ### [MINOR] e2e 11-Expect It split VERIFIED. The new e2e Describe uses a shared BeforeEach (all setup + HTTP calls) with exactly 3 It blocks, each containing exactly one Expect. ### [MINOR] handler.go decodePathsBody if/else VERIFIED. createHandler at lines 154-158 uses a clean if/else branch. decodePathsBody also uses an early-return guard for JSON content type. ## Additional Observation [MINOR] e2e/browser/library_modal_test.go — nil-panic risk in third It block The It("persists the correct path value") block indexes libPaths[0] unconditionally. libPaths is only populated inside "if newLibID != 0" in BeforeEach. If the library was not found (newLibID stays 0), libPaths remains nil and libPaths[0] panics rather than producing a clean Ginkgo failure. Ginkgo runs It blocks independently, so the third can execute and panic even when the second already failed. In a green-path e2e this never fires. Minor, does not block. ## Compatibility checks - Zero-paths-create: paths is nil when no paths submitted; the inner tx loop is a no-op — create-only still works. - No new Grimmory columns: only internal/library/*{.go,_test.go}, JS controller + tests, template, and e2e test changed. No migration files. - Conventions: stat injected as function dep (curried pattern), os.Stat passed at wire time, service tests use closure stubs (stubDirStat/stubMissingStat/stubFileStat). Consistent with project conventions. REVIEW VERDICT: 0 blocker, 0 major, 1 minor
zombor merged commit 473ab51192 into main 2026-06-16 17:16:01 +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!582
No description provided.