Compare commits

...
68 Commits
Author SHA1 Message Date
John O'Keefe a36cee4f85 chore: ignore local export directory
Release / build-and-push (push) Successful in 2m54s
The export/ directory holds database extracts (annotations, reading
positions, book metadata) pulled from production for debugging. The
contents are personal data and must never be committed.
2026-10-12 20:47:05 -04:00
John O'Keefe 14947608be chore(scripts): add synthetic torture EPUB generator
scripts/build_torture_epub.py deterministically builds a small EPUB
exercising every CRE-xpointer to CFI conversion hazard observed in the
wild, one chapter per hazard class: multi-text-node paragraphs (verse
spans, inline markup), whitespace-only inline spans and comments,
drop caps and footnote anchors, br-separated poetry with speaker and
line-number spans, nested blockquotes/lists/tables/definition lists,
surrogate pairs and ZWJ emoji, Hebrew niqqud, Arabic RTL, combining
diacritics, invisible characters, HTML entities, CJK ruby, a 400-
paragraph chapter with heavy phrase repetition for search
disambiguation, and edge constructs (span-first paragraphs, empty
spans, adjacent same-tag spans, deep same-tag nesting).

Default output lands in the Ebooks library for real-device testing
via OPDS; the script takes an output path argument for use as a test
fixture.
2026-10-12 20:46:40 -04:00
John O'Keefe 03a6040360 test(sync): add manual repro harness for CRE-to-CFI highlight conversion
TestManualCleanRepro replays a real device highlight export through the
same conversion path the sync handler uses, diffs the output against
the CFIs stored at ingest time, and computes a ground-truth oracle by
locating each selection in the flattened document itself. Classifies
every row as healthy / diff-base / backwards for both the converter
and the stored data. Skips automatically when fixture files are not
present on the machine (manual test, like cfi_verify_manual_test.go).

REPRO_VERBOSE=1 prints per-row detail; REPRO_EMIT_SQL=<path> emits
idempotent UPDATE statements that heal database rows written by the
old converter from the preserved raw device positions - used to
backfill dev and validated against production data.
2026-10-12 20:46:08 -04:00
John O'Keefe 93a468b435 fix(reader): emit two-subpath range CFIs for multi-node highlights
toRangeCfi required stored start and end CFIs to share a base path and
otherwise fell back to a bare point CFI, which resolves to a collapsed
range and paints nothing - or painted from the wrong anchor after end
repair. A legitimate selection crossing inline elements anchors at two
different text nodes by definition, so requiring a shared base path
discarded correct endpoints.

Build the range parent from the endpoints' longest shared step prefix
(down to the bare spine step when needed) and emit each divergent
remainder per the EPUB CFI range grammar, epubcfi(parent,start,end).
Degenerate-end repair (missing end, or end before start) now only
applies when both sides terminate on the SAME node - equal remaining
step count with different step values is a valid two-node range, not a
degenerate pair; document order is verified for those instead.

Verified with foliate's own parser against the real chapter XHTML:
ranges resolve to exactly the selected text for same-node, two-node,
and repaired-degenerate cases.
2026-10-12 20:45:41 -04:00
John O'Keefe bb3aef517c fix(sync): anchor device highlight ranges to selection text
KOReader highlights converted to wrong or unresolvable web CFIs
whenever a selection crossed text-node boundaries. Three compounding
defects: ParseCREXPointer discarded the text()[N] node index and
applied .offset as a global block offset; the end anchor converted
with an empty context (blind structural landing, zero verification);
and the block-containment check accepted wrong-node landings because
a selection's text always occurs somewhere in its own block. pos0 and
pos1 were converted independently, yielding mismatched start/end base
paths and backwards ranges - highlights stored but never painted, or
painted at the wrong location, in the web reader.

Add ConvertHighlightRange: both endpoints are anchored by locating the
selection text in the flattened document text (whitespace-normalized,
crossing block boundaries), with the device locators demoted to
disambiguation hints. A single document match yields a same-spine,
ordered start/end pair by construction. Falls back to the legacy
per-endpoint path when the text cannot be located, so behavior is
never worse than before.

Also fix the shared flatten stream: zero-width format characters
(ZWSP/ZWNJ/BOM) and whitespace-only inline spans (<span> </span>)
now act as word separators instead of fusing adjacent words, which
prevented text matching in real books.

Verified against 28 real device highlights from production: converter
output now matches the text-search oracle on all of them (previously
8/28 correct, 18 with mismatched base paths, 2 backwards).
2026-10-12 20:45:10 -04:00
John O'Keefe cbdddc095e fix(web): PDF adjust — remove the leftover line-band filter from the endpoint drag
Release / build-and-push (push) Successful in 2m39s
The "full 2D" commit (5a0d9e0) added the tip aim but never removed the
y-band candidate filter from pdfAdjustHandleMove — vertical handle
drags still snapped to the boundary rect's own line, so up/down
movement was impossible (the user's original report). With the filter
gone: the end handle drags down a line and the extent extends with
clean text — verified with real touch on the emulator (down one line,
release, PUT applied: 6 per-line rects).
2026-10-11 20:44:32 -04:00
John O'Keefe 5a0d9e0e17 feat(web): endpoint handles drag vertically — full 2D tip-aimed targeting
User report after the lifted-handle change: "I cannot go up or down,
just side to side" — a consequence of the line-band pin added with the
lift (the floating finger sits above the text; a band-constrained scan
ignored vertical movement entirely).

The endpoint drag is now full 2D: the finger position (plus the
~15px body-center-to-tip offset) maps to page fractions and the
nearest glyph re-anchors the grabbed end — up/down crosses lines,
side-to-side moves along them, clamped so the extent never inverts.
The boundary rect's own band constraint is gone; sliver rects remain
structurally impossible (rects rebuild from the char-table span as
per-line unions).

Direct-fraction probes verified: (0.4, 0.098) → mid line 1; (0.2,
0.117) → line 2 head; (0.72, 0.117) → line 2 mid — spans cluster into
the right per-line boxes. The dot-listener plumbing (pixels → map →
fractions → scan) verified by instrumented probe on the same load.
Final desktop finger pass pending (emulator input pipeline wedged).
2026-10-11 18:15:34 -04:00
John O'Keefe 19619ef817 fix(web): raise the adjust teardrops — tips rest above the glyph tops
User report: the teardrop tip touched the letters. Raised the handle
boxes 8px (tip now rests at the line-box top, ~4px above the glyph
tops) — matches the Android handle spacing the app leg shipped.
2026-10-11 17:57:03 -04:00
John O'Keefe a28ae5c88d feat(web): adjust handles float above the text with tips marking the boundary
User report: the handles covered the highlighted text. The dots now
hang ABOVE the extent's boundary corners (teardrop tip down at the
first rect's top-left / last rect's top-right), so the highlighted
line stays readable.

Same treatment as the app leg: the nearest-char scan in the endpoint
drag is band-constrained to the boundary rect's own line (the lifted
finger floats ABOVE the text — a whole-table scan re-anchored onto the
line above). Cross-line changes go through re-selection as before.
2026-10-11 17:25:58 -04:00
John O'Keefe 1c209408cc fix(web): PDF adjust — three compounding coordinate/table bugs, found by instrumented drag
User report (desktop Brave, fresh private window): the endpoint handle
"jumped several lines down and stuck". The instrumented drag exposed
the chain:

1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the
   dot's pointermove listener passed raw e.clientX/clientY PIXELS.
   Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space
   picks the bottom-right-most glyph — the extent leaped to the page's
   end and never moved again (stuck). Engine-independent: failed
   identically on Chrome/Brave/Firefox desktops; my synthetic tests
   masked it by passing pre-converted fractions. handleMove now maps
   host px → fractions internally (one contract).

2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas
   rect with the host iframe rect. Whether that combination is valid
   depends on the engine's cross-document getBoundingClientRect
   semantics. Replaced with the page WRAPPER element (the iframe's
   parent — the same box the fork's rect-annotation overlay fills),
   measured in a single host context. Handles now align with the
   painted highlights by construction.

3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line
   gap tall). Dedupe at a full box height ate the line-below char in
   the same x-column (silent character loss); clustering at full box
   height never split lines (merged 0.45-tall "line" boxes). Dedupe
   now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of
   the char's own height (inter-line gap ≈ 1.0× breaks, baseline
   variance ≈ 0.1× stays).

Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117)
→ span extends line 1 → line 2, 2 per-line rects saved, clean
multi-line text. Direct-fraction probes and the pointer path now agree
on the same load.
2026-10-11 13:01:16 -04:00
John O'Keefe 93f020f2b5 fix(web): PDF adjust handles — create once, reposition only
User report (desktop): the endpoint handles wouldn't move.
renderPdfAdjustHandles rebuilt the handle layer via innerHTML="" on
every preview frame — the first drag move triggered a repaint that
DESTROYED the dot being dragged. A removed element loses pointer
capture, so real mouse/touch drags died silently after one pixel; the
pointerup never reached the detached dot either, so nothing saved and
the session preview reverted — the same mechanism behind the earlier
"resets to the original selection" report. CDP tests masked it because
synthetic events dispatched directly at the detached node still ran its
listeners.

The two dots are now created once per session and every later call only
repositions them (style updates move nothing the listeners live on).
Grip-era code had this reuse pattern; the endpoint rewrite regressed it.

Real-touch verified on the emulator (adb-injected drag): the handle
tracks, the release saves a multi-line extent with clean recomputed
text (3 rects, PUT applied).
2026-10-11 11:21:51 -04:00
John O'Keefe a3ecac3507 fix(server): Cache-Control no-cache on /static — heuristic caching served stale bundles
The static handler sent no Cache-Control, so browsers fell back to
heuristic caching (10% of age since Last-Modified) and kept serving a
stale reader.js for hours after a deploy — fixed web bugs reported as
"not fixed". no-cache forces revalidation every load; the filesystem
Last-Modified/If-Modified-Since pair keeps unchanged loads cheap.

Found while closing out the PDF adjust v2 session: the user's Chrome
was running a pre-endpoint bundle, which read as "the web has not
been fixed".
2026-10-11 09:50:42 -04:00
John O'Keefe db3b5b3d62 fix(web): endpoint handles verified end-to-end — multi-line extent saved (5 rects, clean text); note: footnote markers between lines are geometrically included by design 2026-10-10 23:19:35 -04:00
John O'Keefe f69e659d94 feat(web): PDF adjust v2 — re-selection replaces the extent; vertical grip clamp
Multi-line partial extents ("line 1's tail + the first words of line
2") were inexpressible with one-rect-per-drag grips, and the vertical
drags produced sliver rects whose text recompute swept fragments. The
web's re-selection uses what creation already uses: a selection made
during a rect-edge session is intercepted in checkPDFSelectionFor —
rects rebuild from the selection's per-line client rects, text re-reads
via the deduped glyph boxes, saved immediately (grip release-to-save
parity), and the create popover is suppressed for that gesture.

Vertical grip drags clamp to the rect's OWN line box (band ±quarter
height) — multi-line changes go through re-selection, slivers are gone.

Also verifies clean under the duplicated text-layer span sets (the
fork's pdf.js render never clears .textLayer — open bug): the text
recompute dedupes by glyph overlap.

CDP-verified end-to-end: session → multi-line selection → 6-rect
extent + clean text persisted (PUT 200); create popover correctly
suppressed during the session.
2026-10-10 18:25:29 -04:00
John O'Keefe dbeefb395c fix(web): PDF adjust text recompute — overlap-based glyph dedupe
User report: extending a highlight doubled letters into the recomputed
selection text ("aauuthenticcity off ... ruunss iinnto"). The exact-
position dedupe key (toFixed(3)) only collapsed duplicate glyphs whose
re-render copies landed inside the same rounding bucket — pdf.js
re-render copies can sit SUB-PIXEL SHIFTED, so per character the jitter
sometimes straddled a boundary and both copies survived, partially.

Dedupe by overlap instead: a glyph is a duplicate when a kept glyph on
the same line (y0 within half a glyph height) overlaps more than half
the narrower glyph's width. Distinct glyphs — including tight pairs —
never reach that; shifted duplicate copies always do. The snap tables
share the source, so their boundary lists are clean too.

The root cause remains the fork's pdf.js render() appending a fresh
span set without clearing .textLayer (open bug, fork-cycle fix); this
is the defensive layer that makes the adjust feature correct regardless.

Repaired the affected highlight's stored text with the fixed recompute
(verified: the same live duplicated DOM now yields the exact line text
with zero doubling).
2026-10-10 14:25:48 -04:00
John O'Keefe 8acaeddda5 fix(web): detail banner — a failed annotation fetch must not wipe its rows
fetchBannerAnnotations published the rebuilt rows unconditionally, with
a failed endpoint's list defaulted to empty — so one failing request
(a single handler 500, an auth blip on one call) silently wiped that
annotation kind from the banner while the other kind stayed, and the
empty state could claim "No notes or highlights on this book yet" over
real data. A full outage was already safe (the thrown fetch takes the
catch path, which keeps previous rows and toasts) — the partial case
was the hole, spotted while mirroring the app's fetch-wipe fix
(bookhoard-app 07bbb4c) onto the web.

Both responses must now succeed before the rows are published;
otherwise the previous rows stay and the next banner open retries
(bannerLoaded only sets on success). Verified on the emulator: banner
renders 3 rows, a full outage keeps them with bannerError recorded,
and a post-recovery refetch re-renders from live data.
2026-10-10 13:56:32 -04:00
John O'Keefe 361fdaab39 fix(web): PDF rect-edge adjust — a start-edge drag pins the far edge
The left-edge drag kept the rect width instead of anchoring the opposite
edge, so shrinking from the left silently dragged the right edge along
and the recomputed text grew words at the far end (found by the Android
device test of the app leg). Same fix for the top edge / bottom pin.
The end-edge branches were already correct.
2026-10-10 11:47:41 -04:00
John O'Keefe 4d1fb05e49 feat(web): PDF highlight extent adjustment — rect-edge grips with word-token snap
The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.

- startAnyAdjust routes the popover button by format; startPdfAdjust
  runs a rect-edge session: 4 grips per rect (all edges, one rect per
  drag), live preview through the renderer's own addRectAnnotation
  (replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
  tokens (start edge takes the word start, end edge the word end), each
  punctuation glyph is its own token with both edges grabbable (a period
  or line-break hyphen can be taken or shed precisely). Horizontal edges
  snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
  rects (reading order, lines joined with spaces) so the stored text
  stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
  relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
  iframe docs are mapped through a WeakMap filled by the view's load
  events. Pointer mapping goes through the canvas's post-transform rect
  (the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
  span set into the same .textLayer without clearing, and undeduped
  glyphs doubled the recomputed text.

Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
2026-10-10 11:22:09 -04:00
John O'Keefe 679e0f9116 fix(server): ghost 401 must abort the chain — c.JSON's nil error let the request continue
Release / build-and-push (push) Successful in 3m21s
The guard returned c.JSON(401, ...), whose nil error told echo the
SuccessHandler succeeded — the pipeline then continued into the route
handler, which type-asserted the never-set "user" context value and
panic-recovered as a 500 appended after the 401 body. The status code
happened to survive (first write wins), which is why the pin test
passed, but every ghost request also executed the target handler with
no user — the exact unauthenticated-execution class the guard exists to
prevent.

Return echo.NewHTTPError(401, ...) instead: non-nil aborts the chain
and renders {"message":"user_not_found: ..."}; the pin test now asserts
that shape. Full suite: 219 groups, zero failures.
2026-10-09 22:41:21 -04:00
John O'Keefe 9cd6356892 feat(server): ghost-session guard — JWTs for deleted users stop authenticating
The JWT middleware validated signature + expiry and fabricated the
request user purely from token claims, so a signature-valid token for a
DELETED user kept full API access until token expiry — observed live on
hardware (a phone session survived the test suite's user-table
recreation and kept serving its true empty sets; open handoff bug 1).

An ensureUserExistsMiddleware already existed but was only wired on the
frontend SSR group (frontend.go) — every /api group used bare
createJWTMiddleware. The existence check now lives INSIDE
createJWTMiddleware's SuccessHandler so every protected group (SSR and
API alike) gets it:

- After claim validation, GetUser by the token's user_id; a missing row
  clears the token cookie and answers 401 user_not_found for API calls
  (the app's TokenAuthenticator then re-logins with stored credentials)
  or redirects to /login?session=invalid for page requests — same
  shape-aware split the error handler already uses.
- Cost: one primary-key lookup per authenticated request (~0.2ms local);
  instant revocation, no TTL semantics. The redundant SSR-only
  middleware is deleted (its redirect behavior is preserved inside the
  guard).

The guard exposed two processing-issues tests that were unknowingly
RELYING on ghost semantics: they nested a second setupTestServer whose
bootstrap deleted the first setup's regular user, then asserted 403
from the dead token. De-nested: a createLibraryDirect helper inserts
the library under the live setup's user, no second bootstrap.

New TestDeletedUserTokenRejected pins the contract: token works while
the user exists -> 401 user_not_found after deletion. Full suite: 219
groups, zero failures.
2026-10-09 22:33:30 -04:00
John O'Keefe 4f05a232fe chore(server): delete dead unrouted UpdateUniversalProgress handler
Handler.UpdateUniversalProgress (handlers/progress.go) had no route
registration anywhere (router/progress.go only wires the history route
to the scanner handler) and no callers — a legacy progress-write path
that bypassed the verify/heal pipeline entirely. Deleting it removes
the last bypass-shaped writer that could poison progress rows without
verification; conflict-resolution apply remains the only deliberate
direct write (user-approved winner rows).

Pure deletion: 115 lines, no route, no callers, no behavior change.
2026-10-09 18:22:23 -04:00
John O'Keefe 69516ae165 feat(server): progress locator coherence — percentage-only writes clear stale anchors
The verify/heal pipeline verified only INBOUND context-bearing writes;
stored-row coherence was never enforced. SaveProgress seeds missing
fields from the existing row, so every percentage-only write (KOReader
low-confidence conversion, KOReader no-pointer payloads, Kobo markup
ReadingSync / analytics / sync-from-server, locator-less queue entries)
silently re-paired its NEW percentage with the PREVIOUS writer's stale
CFI+context — the incoherent row shape that fed the client-side
far-anchor restore floor (and, via the read-side anchor handles, could
even hand a first-party client the OLD position's scroll address).

KOReader's converter already declared the right intent ("storing
percentage only — the row's percentage restores approximately"); the
seeding betrayed it. SaveProgressRequest gains ClearLocator, set by the
percentage-only call sites, and the seeding rule honors it: stale
epubcfi/context/character_offset clear (the upsert assigns columns
directly, so NULL sticks) unless the request carries its own. A
request-carried context survives so the verify/heal pass can still mint
a fresh anchor from it; the HTTP merge path (field-absent PUTs
preserve) and all anchor-bearing writes are untouched. The fixed-layout
branch already cleared locators unconditionally — unchanged.

Consumer audit: percentage — the only thing every sync round-trip
exchanges — is untouched on all paths; KOReader pulls on locator-less
rows already support the nil-CFI shape (builder guards
progress.Epubcfi.Valid); first-party restores go percentage-first
instead of floor-fighting a stale anchor.

Tests: TestProgressWeb_LocatorCoherence pins clear-on-declared,
request-carried-context survival, and merge-path preservation. Full
suite with the change: 218 groups, zero failures.
2026-10-09 18:22:15 -04:00
John O'Keefe f24a2d6c22 test(server): fixture collisions fixed, stale contract asserts re-anchored, gofmt — suite now fully green
Closes the remaining integration-suite failures (handoff open bug 3),
line by line:

- Fixture family (the upsert equivalence): tags_filter_test
  ("Science Fiction" found 1 of 3; "Fantasy" 404'd on zero results),
  media_item_isbn_test (normalization read back the first case's ISBN
  everywhere; edge cases saw a nil ISBN; pagination collapsed 5
  creates onto 1 row), search_unified_test (its 3 books were actually
  1 row + two no-op creates) — all POSTed a constant file_path into
  one library while CreateMediaItem upserts ON CONFLICT
  (library_id, file_path). Every fixture now uses unique per-item
  paths, the suite's universal convention elsewhere. TestTagsFilter
  confirmed same-family: counts came right with no second bug.
- New TestMediaItemCreateDuplicatePath pins the honest-create
  contract from da13898: 201 for the first create; 200 + duplicate
  flag + the EXISTING row (never a 201 lie), second payload not
  overwritten.
- library_browse_test: the admin subtest browsed the container's
  /tmp and required it non-empty — env-coupled (empty /tmp → "was
  []"). Now browses a self-created t.TempDir with two subdirs and a
  decoy file, asserting ONLY directories are listed.
- series_integration_test: three stale assertions re-anchored to the
  shipped contracts, each with a ground-truth comment: /dashboard/
  sections key identity on the collection NAME (BuildSections ID =
  CollectionName; the web/app reorder + hidden prefs persist those
  strings) — the kebab query_type is the storage key and is not
  exposed; /api/series/books is name-ONLY (the app client and the
  web SSR page both send name only; library_id is ignored, never
  validated); /api/series is optional-scope BY DESIGN (GetDistinct-
  Series: narg IS NULL OR library_id = narg aggregates across
  libraries when omitted). The former 400-on-missing-library_id
  expectations asserted validation that never shipped.
- gofmt rider on three untouched-in-this-arc test files
  (dashboard_integration, search, test_helpers).

Full suite with -parallel=1: 217 groups pass, zero failures.
2026-10-09 17:11:12 -04:00
John O'Keefe da13898046 feat(server): POST /api/media-items answers honestly on a duplicate file_path
The create endpoint shared the scanner's upsert (ON CONFLICT
(library_id, file_path) DO UPDATE SET updated_at = NOW() RETURNING *) —
so a POST naming an already-registered file returned 201 CREATED with
the OLD row while silently discarding everything the caller submitted.
Nothing depended on that lie: the app only GETs/PUTs media items, the
web only searches/purges/deletes, KOReader never calls it — the
endpoint's create path had zero production callers.

Split the API path from the scanner's idempotent upsert, which is kept
verbatim in CreateMediaItem (re-scan dedup remains its safety net):

- CreateMediaItemIfNew: same insert with ON CONFLICT DO NOTHING — the
  loser of a create race writes nothing and returns no row.
- GetMediaItemByLibraryAndPath: fetch the existing row for the
  duplicate response.
- Handler branch: on pgx.ErrNoRows → 200 OK with
  {"duplicate": true, "message": ..., "item": <existing row>} instead
  of a 201 pretending it created something. Real creates still answer
  201 with the item as before.

Bulk-import shape (the reason for 200+flag over 409): a re-run after a
mid-import failure is harmless and countable — each response tells the
caller exactly which of the two things happened.

Verified by the full integration suite: 217 groups pass, zero failures,
including a new pin of the duplicate contract (same file_path twice in
one library → 201 then 200/duplicate:true, second payload not
overwritten).
2026-10-09 17:10:56 -04:00
John O'Keefe 1a2d42a244 chore(server): delete dead ParseEbook 501 placeholder
Born dead in e7ce0cb (2026-04-03, backend reader infrastructure):
the "Phase 2.5" plan assumed MOBI/AZW3/DOCX/RTF would need server-side
parsing, so ParseEbook was written as the endpoint that would do it —
but it only ever returned 501 "not yet implemented", and no route in
router/reader.go ever pointed at it. No caller in Go, templates, or
web JS/TS ever existed either.

The eventual architecture never needed it: the web reader hands the
raw file to foliate-js and parses client-side (MOBI renders via the
fork's mobi.js — verified live; DOCX via mammoth; RTF/PDB shipped).
By now it was also stale — it 501'd DOCX/RTF as "not yet implemented"
while both work, and offered an AZW3 path that was dropped entirely.

Pure deletion: no route registration, no references, no tests, no
behavior change. go build ./... clean.
2026-10-09 15:05:44 -04:00
John O'Keefe 208fc2c4d6 fix(server): kobo markup panic — wire LibraryService into the test harness, guard typed-nil resolver
TestProgressWeb_KoboIntegration 500s'd on every markup with a
last-read-place bookmark: the panic-recovered stack was a nil pointer
dereference in LibraryService.ResolveMediaPath (s.db deref), reached
from KoboHandler.convertKoboCFIToStandard during kepub CFI conversion.

Two-layer bug:

1. Test harness (root cause): the router.Config built in
   test_helpers_test.go omitted LibraryService, so router/sync.go's
   SetLibraryService(cfg.LibraryService) stored a nil
   *services.LibraryService in the interface field. Production wiring
   (main.go:246) was always correct.
2. Latent trap (hardening): `h.libraryService != nil` at the
   convertKoboCFIToStandard call site can never catch a typed nil —
   an interface holding a nil *LibraryService is non-nil. ResolveMediaPath
   now degrades with a "library service not configured" error instead
   of panicking on a nil receiver; callers already handle the error
   by skipping CFI conversion.

Harness-only fix, zero production behavior change: the endpoint now
returns a clean 200 with the position saved wherever kepub conversion
is unavailable instead of a panic-recovered 500.

Verified: TestProgressWeb_KoboIntegration passes all three subtests
(401 unauthenticated, ReadingSync→last-read-place percentage
preservation, last-read-place without prior ReadingSync) and on
immediate rerun — previously it failed every run.
2026-10-09 14:53:48 -04:00
John O'Keefe 18969a0f01 test(server): comic negative-case assertions target display values, not admin-modal labels
The whole-body "should not display Manga Type / Scan Info" assertions
could never pass: every book-detail page for an admin carries the
edit modal, whose form labels ("Manga Type", "Scan Information") and
option texts ("Yes (Right to Left)") share vocabulary with the
metadata display block. This was pre-existing rot — the test never
ran to completion before today because an earlier panic killed the
process first.

Replaced with value-level checks that only the display block could
produce ("yes and right to left", "B&W"): absent for plain ebooks and
unknown-manga-type items, matching the templ guards in
book_detail.templ. Emoji-icon asserts dropped from the RTL/Story-Arc
positive steps — the redesigned comic badge renders text chips
without icons.
2026-10-09 11:04:39 -04:00
John O'Keefe 843f35bbfc test(server): runs leave no residue — auto-cleanup for every resource the suite creates
Symptom: repeated full-suite runs poisoned the dev database with
leaked rows (duplicate Prefs Scope libraries, colliding device names,
saved-filter name conflicts on rerun) because tests created rows but
never deleted them, and stale-binary runs left rows the new cleanup
could not match.

- test_helpers: library creation now registers t.Cleanup DELETE (the
  leak root cause — createTestLibraryWithFolder never cleaned up).
  Suite-wide cleanup passes for libraries/collections/devices/
  saved-filters run both at setup (pre-pass against legacy residue)
  and in t.Cleanup (no per-run residue). Preserve the admin's real
  libraries; delete only convention-named test rows.
- setup seeds canonical system collections every run keyed on
  query_type (idempotent — heals renames/stale tombstones), moved out
  of the preserved-admin branch so it runs for every suite start.
- dashboard_integration: scope-testing library renamed into the
  test-row convention; ordering assertions on the restored
  "Want to Read" collection preserved.
- filters_test: filter names moved into the convention (rerun-safe —
  name-uniqueness 409 no longer trips second runs); five  .(string)
  type assertions panic-proofed with require.True guards (a panic here
  used to kill the whole test process mid-run, which is why a stack of
  "never ran" failures surfaced at once later).
- device_test: device names moved into the convention (ILIKE %test%
  hit them; duplicate-device-name conflicts gone).
2026-10-09 11:04:34 -04:00
John O'Keefe 7ce73213ae feat(server): system collections become permanent — soft-delete + in-place restore + query_type-keyed provisioning
The old model hard-deleted system collections and re-created them at
the tail, resurrecting stale dedups and breaking ordering. Redesign
(user-directed B-final):

- schema.sql: collections.deleted_at TIMESTAMPTZ (idempotent ALTER —
  the whole schema is re-applied on every boot via executeSchema).
- queries.sql: every collection list/read query filters
  deleted_at IS NULL; new GetSystemCollectionByQueryType :one
  (unfiltered, sees tombstones), TombstoneSystemCollection :exec,
  ClearCollectionItems :exec, RestoreSystemCollectionRow :one
  (in-place metadata reset + un-tombstone; collections have no
  updated_at column — ResetUpdatedAt was a 500 caught by tests).
  sqlc regenerated.
- collection_service.DeleteCollection: system collections are
  tombstoned (preserving position/ownership); user collections keep
  the hard delete. No ErrNoRows to the client (delete is idempotent).
- dashboard_service.RestoreSystemCollection: resolve by query_type;
  found → in-place restore (ClearCollectionItems + metadata reset,
  un-tombstoned); missing → provision fresh. "Want to Read"/etc.
  stay at their home positions forever.
- handlers/collections.CreateCollection: name-taken is 409, not 500.
- handlers/auth.CreateDefaultCollectionsForUser: idempotent by
  (user_id, query_type) — update-in-place when a row for that query
  type exists (heals renames/tombstones), insert when missing; this
  also fixes the duplicate-system-collections regression at source.
2026-10-09 11:04:24 -04:00
John O'Keefe 31f939002f feat(web): 'a' keyboard shortcut toggles the annotations drawer
The drawer was the only top-bar tool without a keyboard shortcut, so the
shortcuts & gestures help modal had to describe it as '📝 button'. Bind
plain 'a' to toggleBookmarks(), following the same pattern as the
existing t/s/b letter shortcuts.

- reader.ts: new 'a' branch in handleKeydown, guarded against
  Ctrl/Cmd/Alt so Select All (a common reader action when selecting
  text to highlight/copy) is never intercepted; typing guard already
  protects note textareas, search box, and selects. Works in both the
  host page and book-content iframes since both keydown listeners route
  through handleKeydown.
- reader.templ: help modal row now shows <kbd>a</kbd> like the other
  letter shortcuts; 📝 button tooltip gains '(a)' to match its
  siblings (b), (/), (s), (?).
- reader_templ.go: regenerated.
2026-10-08 15:12:27 -04:00
John O'Keefe f83b36167e style(web): location-note rows lose the color bar — only highlights carry a color (user review) 2026-10-08 14:57:41 -04:00
John O'Keefe d5d8e3e173 style(web): drop the NOTE label from the expanded boundary — divider reads on its own (user review) 2026-10-08 13:42:25 -04:00
John O'Keefe 44c0e5640f fix(web): banner highlight-note dialog prefilled; explicit note boundary; pencil on every note row
- BUG: startBannerEdit extracted the note by splitting row.rich — the
  RENDERED HTML — on \n\n, which the concatenated markup never
  contains, so highlight-note dialogs always opened empty. Both row
  kinds carry the raw note on bannerSources; use that lookup.
- Expanded body now splits at the highlight→note boundary: the
  selection paragraph (left border in the row's highlight color), a
  divider + pencil NOTE label, then the note markdown — instead of the
  two texts flowing together.
- The note-indicator pencil (corner badge) now shows on ALL note rows,
  not just highlights with attached notes.

Verified in headless Chromium: badge on note and highlight rows;
expanded highlight shows the bordered selection, NOTE label, and note
markdown; the highlight edit dialog prefills with the note text.
2026-10-08 09:11:47 -04:00
John O'Keefe e7ccd7b222 test(dashboard): align test seed naming with the canonical Title-case set + fix stale expectations
The test helper createDefaultCollectionsForUser carried its own
kebab-case copy of the default system collections — drifted from the
canonical Title-case list the registration seed and
RestoreSystemCollection use. Every suite run against the shared dev DB
re-created a kebab set beside the canonical one (the duplicate sections
on the customize screens); the dev DB is cleaned and the helper now
uses the canonical list.

Two stale expectations corrected while re-running the suite:
TestGetSections_EndToEndFlow asserted the kebab ids (now asserts the
Title-case names — section ids ARE collection names), and
TestGetSections_MissingLibraryID expected a 400 the handler never
implemented — absent library_id is the All Libraries view by design
(now asserts 200 + aggregated sections).
2026-10-07 20:32:36 -04:00
John O'Keefe b1de0da512 feat(web): dashboard All Libraries view saves the global scope
The All Libraries entry in the library switcher could render the
cross-library dashboard but rejected its own settings: the modal
refused to open without a library ('Select a specific library...') and
a save sent library_id: '' (server 400). The server now stores a
global scope (separate table; null/absent library_id, empty string
still 400), so:

- openDashboardSettings allows the All view and fetches prefs without
  the library param (reads the global row; previously a loose NULL
  OR-clause matched an arbitrary library's row).
- saveDashboardSettings sends library_id: null from All Libraries.
- The settings modal gains a scope label (#dashboard-scope-label)
  showing which library — or All Libraries — a save will customize
  (user requirement: always know the scope).

Verified end-to-end in headless Chromium: reorder on All Libraries →
save (null) → reload → modal re-opens with the persisted order and the
All Libraries scope label; per-library save lands in its own row
without touching global (SQL-checked).
2026-10-07 19:51:59 -04:00
John O'Keefe 25d44452c0 feat(dashboard): global (All Libraries) preferences scope
The dashboard's All Libraries view could render sections across every
library but could not persist its own preferences: the PUT required a
UUID library_id, and the prefs GET-without-library_id matched an
arbitrary library's row through a loose NULL OR-clause.

Storage: a dedicated user_dashboard_preferences_global table (one row
per user, user_id PK → users CASCADE) instead of a NULL/sentinel
library_id — chosen over both because (a) the column's FK to libraries
rules out a sentinel UUID without dropping the constraint or minting a
fake library row, and (b) intent becomes structural: only the explicit
global path writes that table, so a buggy or outside-the-API write can
never masquerade as global. System and user collections are
user-scoped, so the order/hidden arrays are library-independent and
valid in this scope.

API: PUT /api/dashboard/preferences binds library_id as *string —
JSON null/absent = global, empty string = 400 (a lost value must stay
loud, not silently clobber the global scope), valid UUID = per-library
(unchanged). GET without library_id now returns the global row instead
of an arbitrary one; the per-library query's dead NULL OR-arm is
removed. GET /sections keeps requiring library_id for per-library
views and aggregates across libraries when absent (unchanged).

Tests: global save/update-dedupe/read round-trip, empty-string 400,
global-vs-per-library independence. Two suite failures are the
handoff's documented pre-existing rot (duplicate system collections in
the dev DB; a sections test expecting a 400 the handler never
implemented) — reproduced and confirmed unrelated to this change.
Live-verified on the dev stack: null save 200 x2 with a single row
updated, empty string 400, global read carries the second save.
2026-10-07 19:46:17 -04:00
John O'Keefe b8db1d109c feat(web): note-attached badge on banner highlight rows — corner stroke glyph
Highlights with an attached note get a small edit-note stroke SVG
(same note-pencil path as the selection popover's Note affordance) in
the card's upper-right corner — themed via var(--text-secondary),
pointer-events: none so taps pass through, header gains pr-8 on those
rows so wrapped text never runs under it. Placement matches the
Android banner's corner badge (user preference: never left of text).
Regenerated annotations_banner_templ.go from the repo root.
2026-10-07 15:30:26 -04:00
John O'Keefe bbecd1e5ec fix(web): rich markdown notes get block structure — paragraphs, quotes, lists, code
Tailwind preflight zeroes p/blockquote/list margins globally, so notes
rendered through renderNoteHtml collapsed flat: blank lines between
paragraphs vanished, blockquotes were indistinguishable from body text,
lists had no bullets/indent, code blocks had no padding. The parser was
always correct — the loss was CSS at the x-html display sites.

One shared scoped block (.note-rich, in @layer components so it
outranks base preflight): p+p margin-top, blockquote bar + indent +
secondary color, list markers + padding, pre/code padding + themed bg,
modest heading sizes, underlined links, table borders, em-based so it
scales with each site's text-xs/text-sm context. Applied at all three
display sites: reader notes-drawer rows (replaces the unused
reader-note-rich hook), the 📝 highlight note line, and the banner's
expanded body.

The regenerated _templ.go files also catch up two stale .templ changes
that had shipped without regeneration (the selection popover's Adjust
button, the banner delete ternary) — the Docker build regenerates at
image-build, so the served site always matched the sources.

Verified live (headless Chromium against the dev stack): .note-rich in
the served stylesheet; p+p computed marginTop 9.6px, blockquote 3px
solid border-left, ul disc + 22.4px indent, pre padded + themed bg.
2026-10-07 08:01:41 -04:00
John O'Keefe ffe690691b fix(web): facedown teardrop handles (standard Android selection fashion) 2026-10-05 19:16:28 -04:00
John O'Keefe 0708a1f414 fix(web): mirrored handle teardrops — end handle points right 2026-10-05 17:29:06 -04:00
John O'Keefe c8e85817aa feat(web): highlight extent adjustment — draggable handles with word/punctuation snap
Objective 9 (web half). The selection popover's edit mode gains an
Adjust button: the overlay's show-annotation event stashes the live DOM
range, start/end teardrop handles render over it (host layer above the
iframe), and pointer drags map into the iframe's caretRangeFromPoint.
Snapping: land next to punctuation → the boundary sits exactly there;
land inside a word → the word's edge in the drag direction; whitespace
trims toward the adjacent word. A live preview repaints through the
overlayer under a scratch key; relocation (page turn) cancels safely.

Release recomputes the range CFI + collapsed-end CFI (view.getCFI) and
saves through the popover's own saveHighlightChanges path (PUT +
remove-old/add-new overlay dance), keeping selection_text and the
anchors in lockstep.

Verified live E2E in Brave: extent extended by one word with the
comma/space snap behavior, saved to the server row, handles cleaned;
all snap paths unit-exercised in-page (word end/start, punctuation
exact on both sides, whitespace trim); the test highlight was restored
to its original extent through the same adjust flow (punctuation-precision
drag back to the comma).
2026-10-05 16:50:27 -04:00
John O'Keefe f45adc6362 fix(web): banner highlight delete; retention wording states the days
The book-detail banner's highlight rows gain Delete (tombstoned
server-side, restorable from the Deleted modal — same as the reader
drawers already offered). The deleted-annotations modal now says the
retention in plain language — 'kept for 30 days' from the
annotation_tombstone_ttl_days tunable (threaded through BookDetail →
NotesHighlightsModal; 1-day singular and retention-disabled variants
handled) — instead of referencing a settings name a reader cannot see.
2026-10-05 12:15:45 -04:00
John O'Keefe 5275f2ba38 feat(web): note editing at the reader slider and book banner; Deleted rename
Objective 8a (web half). Reader notes-drawer rows gain an inline editor
(mirroring the bookmark-rename pattern) that PUTs the new content — the
stored position rides along because the server's note PUT hard-sets the
position column (a probe note proved an omitted position NULLs the
anchor). Book-detail banner rows gain Edit/Delete for location notes
and Edit-note/Add-note for the note ATTACHED to a highlight (full-replace
highlight PUT; empty content clears the note; the highlight itself is
never edited from the banner). The edit dialog is centered with the
markdown caption.

The book-detail 'Notes' button becomes 'Deleted' (badge =
deleted-annotation count) and the modal title becomes 'Deleted
annotations' — that modal only ever showed removed annotations.

Verified live in Brave: drawer inline edit round-trips content and
position; banner note edit, highlight-note edit, and delete (tombstone
appears in the Deleted modal); rename renders with the count badge.
2026-10-05 11:36:14 -04:00
John O'Keefe 0b83db8fce feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export
Release / build-and-push (push) Successful in 3m9s
Objective 7 (web half). The 'Annotations' button slides a right-hand
banner (annotations_banner.templ + annotations-banner.ts) fed by the
existing per-book GET endpoints: friendly labels (Section N parsed from
the CFI spine / Page N from the PDF envelope / NN% + first-words
fallbacks), capped highlight previews, rich x-html expansion through the
objective-6 markdown pipeline, and per-row 'Open reader here' jumps
riding /readers/:uuid?loc= (consumeLocJump: jump outranks stored
progress for that open; URL stripped before sync can echo it). WEB-ONLY
export: single .md or one-file-per-item zip via fflate — item format
identical both modes ('# friendly label' / '# selection text' headers,
raw markdown bodies, no raw CFIs anywhere).

Verified live in Brave: 12 restored highlights + a markdown note
labeled/expanded correctly; the jump navigated, landed on the right
chapter and consumed ?loc=; export produced the 13-item .md (friendly
headers only) and a valid 13-entry zip. Book-detail's existing
recently-deleted modal stays untouched (banner is additive).
2026-10-04 14:30:56 -04:00
John O'Keefe 4b5e193d9f feat(web): notes render as markdown — markdown-it+DOMPurify pipeline, rich paste
Objective 6 (web half). Storage/sync stay raw markdown (KOReader shows
literal source, accepted). Notes-grade syntax: headings, emphasis/
strikethrough, code, links, lists, blockquotes, GFM tables, reference
links, footnotes (markdown-it-footnote); images/raw HTML/math excluded
(html:false posture + FORBID img/style/form + default safe-scheme URI
check). Display sites: annotations-drawer note rows and highlight note
lines render pre-sanitized HTML (x-html); render-time sanitize only.

Rich paste: both note textareas intercept paste; a text/html clipboard
flavor converts via turndown (gfm tables incl. a headingless-table rule
emitting pipe syntax; images blanked) and inserts at the caret; plain
pastics fall through to the default paste unchanged.

Verified live in Brave: bold/italic/strike/code/lists/quote/table/
footnote-ref/reference-link render; <script> renders inert literal
text with no alert; javascript: hrefs absent from the DOM; paste of
rich HTML lands markdown at the caret; plain pastes untouched.
2026-10-04 13:25:17 -04:00
John O'Keefe b9f797789f feat: registration_enabled admin setting — hides signup surfaces, gates the API
Release / build-and-push (push) Successful in 3m9s
One boolean (Security > Public Registration) switches the whole public
signup lifecycle: front page + login links, the logged-out sidebar's
Create-an-account (via a package-level templates hook so shared page
templates keep their signatures), GET /register -> 302 /login, and an
early 403 on POST /api/auth/register. First-user exception preserved:
zero admins keeps every route and link reachable for bootstrapping —
same 'users exist' reasoning as the setup gate. Admin user creation is
unaffected by design.

Live E2E verified: flag off hides all surfaces and blocks the POST
(403), flag on restores them (201); the admin UI renders the row
automatically.
2026-10-03 23:54:07 -04:00
John O'Keefe 6aaefe210a pin foliate-js b7597d6 — paginator teardown-race guards (v0.5.1+0.5.2) 2026-10-03 23:10:21 -04:00
John O'Keefe fb21d5249c fix(rescan): updateMediaItem re-derives format flags — force rescan reclassifies rows 2026-10-03 22:40:40 -04:00
John O'Keefe b03ed9a2f2 fix(compose): publish db on loopback only — was reachable from any LAN
The unqualified 0.0.0.0 publish put Postgres on every interface, and
Docker delivers published ports through PREROUTING DNAT into the
FORWARD path — ufw's default deny incoming never sees those packets.
Result: the database was reachable from whatever network the laptop
joined (home, guest Wi-Fi, hotel), not just from the host.

Bind to 127.0.0.1/[::1] instead: with no DNAT matching LAN-destined
packets, they fall back to INPUT where the firewall actually applies.
Host-side tools keep working over the loopback publish (both families
bound because localhost may resolve to ::1 first); app↔db and tests↔db
are untouched — they use the db service name on the compose network,
which never traverses iptables on this host (br_netfilter not loaded).

If LAN access to the DB is ever wanted again, revert to an unqualified
publish and rely on DOCKER-USER home-subnet scoping instead of an
open binding.
2026-10-03 21:35:15 -04:00
John O'Keefe 72839921f2 drop DOC and LIT from the pipeline (deliberately unsupported)
.IMAGE  — extensions removed from AllowedExtensions (scan gate),
bookExtensions, MimeTypes and sync/format.go maps: .doc/.lit files
are no longer scanned or indexed. classifyFormatGroup gains the
RTF and PDB reflowable arms that were missing when those rungs
shipped (rows only reclassify on creation). Scanner docs updated.

DOC + LIT have no credible JS tooling (mammoth is docx-only; LIT
needs LZX and its DRM variants are dead) and text-extraction-only
support would misrepresent what the reader can do. Verified: probe
.doc/.lit files watched but never scanned; probe .txt control
scanned; TestClassifyFormatGroup extended and passing.

Docs change granted in-session by the user (extensions + format
docs).
2026-10-03 16:11:04 -04:00
John O'Keefe 25981fc762 pin foliate-js ac699e7 — RTF + PalmDOC support (fork v0.5.0) 2026-10-03 15:41:06 -04:00
John O'Keefe f2629942e4 fix(scanner): classify .docx as reflowable — was landing format_group unknown
.docx is accepted for ingest but the format-group switch had no case
for it, so every DOCX row stored format_group 'unknown' with
is_reflowable=false. Extract the classification switch into a pure
classifyFormatGroup helper with a table test, and classify .docx
reflowable like the other reflowable text formats (.rtf/.doc stay out
until they are readable client-side). Existing rows: dev alice (DOCX)
reclassified by hand; production rows reclassify on rescan.
2026-10-03 14:17:58 -04:00
John O'Keefe ad1ad11fb5 docs(bruno): document the 100k note content limit
Release / build-and-push (push) Successful in 2m49s
Create/Update Media Note request docs still said "1-10000 chars";
update both to match the raised validator cap.
2026-10-02 21:31:18 -04:00
John O'Keefe 66dd6c8096 feat(reader): note length counter and save guards in the reader UI
Oversized notes previously failed only at save time, surfacing the
raw backend validator error to the reader. The reader now handles
the limit inline and keeps oversized input editable:

- Live character counter under both note textareas (annotations
  drawer "Add a note" and the selection popover note editor),
  formatted "12,345/100,000" — muted normally, red once over
- Pasting is never truncated; the full text stays in the textarea
  so the reader can shrink it however they see fit
- The three save actions (Add Note, Highlight with note, Save
  note) disable while over the limit, with a toast fallback in
  addNote / createHighlight / saveHighlightChanges so the guard
  holds even outside the disabled-button path
- noteMaxLength mirrors the server-side cap (100,000, see
  internal/handlers/media.go) so client and server stay linked

Also truncate note rows in the drawer list — highlight rows
already truncated, but a long note previously stretched the
drawer body.

The resize handle needed no work: .reader-note-input already
ships resize: vertical in the compiled CSS; style.css is rebuilt
only for the new disabled:* utility classes.
2026-10-02 21:31:13 -04:00
John O'Keefe 1205526422 feat(notes): raise annotation text limit from 10,000 to 100,000 chars
The 10k validator cap rejected legitimate long-form notes — a
scholarly reading note with quoted passages and footnotes lands
around 10.1k chars and failed at save with the raw validator error.

Raise the cap to 100,000 on all six annotation text fields in the
media handler request structs:

- CreateMediaNoteRequest / UpdateMediaNoteRequest Content
- CreateMediaHighlightRequest / UpdateMediaHighlightRequest NoteText
- CreateMediaBookmarkRequest / UpdateMediaBookmarkRequest Notes

No other layer changes: the media_notes / media_highlights columns
are unbounded TEXT, and the KOReader + websocket sync paths never
had a length cap, so the REST API now matches the rest of the
system instead of being the strictest gate.

New integration test pins the boundary: 50k and 100k-char notes
return 201, 100,001 chars returns 400.
2026-10-02 21:31:03 -04:00
John O'Keefe 29ac420876 chore(deps): bump @bookhoard/foliate-js to 50f56ee — paginator guards for not-yet-loaded section documents 2026-10-02 16:40:05 -04:00
John O'Keefe 5ffa371963 chore(deps): bump @bookhoard/foliate-js to 8e1d5fc — DOCX support via mammoth 2026-10-01 14:30:50 -04:00
John O'Keefe 0be70f0051 docs: drop AZW3 from documented format expectations
AZW3/KF8 is a proprietary legacy Amazon format (the pipeline moved to
KFX in 2015; KDP dropped MOBI-family uploads in 2022) and does not
render in the web reader. The scanner still ingests .azw/.azw3 files
(code unchanged) — they are simply no longer documented as a supported
expectation.
2026-09-30 21:22:44 -04:00
John O'Keefe f07dfbfe74 chore(deps): bump @bookhoard/foliate-js to cd2fbf2 — plain-text (.txt) renderer
makeTextBook: UTF-8 decode, blank-line paragraph split, hard-wrapped
lines joined, HTML-escaped, ~150KB section cap at paragraph boundaries
(Part N TOC). Enables the web reader for .txt (previously ingested but
unreadable client-side). Positions for txt books are percentage-based
(sections get index-derived fake CFIs).
2026-09-30 15:48:55 -04:00
John O'Keefe b4b1deafb8 docs(user): Android app guide — LAN setup and the Android 16+ local-network permission
Android 16+ gates per-app access to local networks behind a runtime
permission; without it the app's LAN logins time out with zero packets
leaving the phone (browser works — it is not per-app-gated). The new
device guide covers the sideload install, first-run server URL, the
permission prompt (and manual re-grant path), and a triage table for
the login screen's error line. Per the user: overlay-VPN addresses are
not a consideration and stay undocumented.
2026-09-29 18:12:57 -04:00
John O'Keefe 217f411f74 fix(auth): GetProfile reads the user row — the JWT context stub has no theme
GetProfile returned the theme from the JWT-claims context stub, which
carries only id/email/username/role — user.Theme was always empty, so
the profile response omitted the field and the app's account-theme
read-back (SERVER sync mode) could never see the stored theme. GetProfile
now loads the full user row by id (h.db.GetUser).
2026-09-28 08:45:01 -04:00
John O'Keefe 4fde2a6d83 feat(auth): GetProfile returns the account theme
The Android app's app-chrome theme (UX pass item 6) reads the account
theme from GET /api/auth/profile and re-applies it at startup in
SERVER sync mode. PUT /api/auth/theme already persisted the value;
this completes the round trip on the profile read.
2026-09-28 08:34:11 -04:00
John O'Keefe 3d88f0b960 feat(search): publisher_filter for the media search endpoint
Adds publisher_filter to SearchMediaItems/SearchMediaItemsUnified
(fuzzy word_similarity against mi.publisher, mirroring genre_filter),
plumbs it through services.SearchParams and the search handler, and
extends the GREATEST relevance ranking to include publisher matches.
Serves the app's author/publisher/genre/tag click-through browses
(UX pass item 5 — publisher was the only facet without a server
filter).
2026-09-27 19:55:58 -04:00
John O'Keefe 6aea6a4e2c fix(sync): countTextCharsBefore multiplied the document by node depth
Release / build-and-push (push) Successful in 2m57s
countTextCharsBefore started its sibling walk at the target node itself
and recursed after counting the current node's subtree, so every
ancestor level re-counted everything accumulated so far — a node at
depth 4 in the single-document 1984 epub reported 2.38M chars before it
in a 589k-char document, producing healed percentages of 4.04 (>1) and
a 500 on the reading_progress percentage check constraint. Every
healed progress save from a fresh client failed; only exact-context
matches (no heal) stored.

The walk now starts at target.PrevSibling: strictly the characters
before the node, per the contract all three callers already assume
(healed book offsets, CRE convert percentage, kepub offsets).

Found by the real-phone validation pass: the phone's first healed
submission 500ed where the emulator's had matched context exactly and
never taken the heal path.
2026-09-26 22:01:35 -04:00
John O'Keefe b10bf3e8c7 docs(api): real progress endpoint contract; remove nonexistent-endpoint docs
The progress documentation described GET/POST /api/progress/:id —
endpoints that do not exist in the router — while the endpoint every
client actually uses (GET/PUT/DELETE /api/media-items/:id/progress) had
no field-level docs at all.

New:
- progress/position-contract.md: the canonical position model — server
  as position authority, the three-tier submission (percentage /
  context_text / epubcfi), ingest verification and healing, the restore
  handles, the OPF spine numbering hazard (canonical CFI spine steps
  include linear="no" items; clients resolve documents by anchor_href
  and land by css_selector + char_offset, never by spine step), the
  offset currencies (UTF-16 at the wire, runes internal), and
  context_text rules.
- progress/get_media_progress.md and update_media_progress.md: the real
  endpoints with full field tables, conditionality of the restore
  handles, the first-page anti-clobber guard, and healed-response
  semantics.
- progress/delete_media_progress.md: the real DELETE route.
- koreader/sync_progress.md and koreader/get_metadata.md rewritten to
  the actual payloads: the plugin sends a single-book array whose
  "epubcfi" field is a CRE xpointer; the metadata response navigates
  via koreader_xpointer (canonical CFI converted back to CRE), with
  page as the canonical locator for fixed-layout books.

Removed: the five files documenting the nonexistent /api/progress/:id
GET/POST/DELETE endpoints. Kept get_progress_history.md (that route
exists). The legacy developer/api-reference.md and the indexed
api/api-reference.md progress sections now match the wire and link the
new docs; the duplicate "Universal Progress" section points at Reading
Progress.
2026-09-26 21:27:44 -04:00
John O'Keefe 8c3273a0fc fix(sync): normalize character offsets to UTF-16 at the wire; refresh book offset on every verified save
Offset currency policy, now explicit: EPUB CFI terminals, CRE text()
offsets and the served char_offset handle are UTF-16 code units (the
EPUB CFI spec, and what foliate/readium/KOReader/Kobo clients actually
observe), while internal arithmetic — the book-wide character_offset
column and percentage fractions — stays rune-based, consistent with
TotalCharacters. For all-BMP books the currencies are identical, so no
stored value changes; astral-plane text (emoji, rare CJK) no longer
drifts.

Boundaries converted: resolveCFIToNode interprets incoming CFI terminal
offsets as UTF-16; textNodeAtUTF16Offset (née textNodeAtRuneOffset)
interprets CRE text() offsets as UTF-16; buildCFI and buildCREXPointer
emit UTF-16 terminals; blockCharOffset (the served char_offset) is
UTF-16.

Also fixes two character_offset column defects: heals wrote a BLOCK-
relative offset into the book-wide column, and verified-but-unhealed
saves (e.g. KOReader pushes) never refreshed it, leaving it stale
behind the anchor. VerifyProgressAnchor now returns the verified book-
wide rune offset and SaveProgress refreshes the column on every
verified save.

Tests: astral currency round trip (offset after an emoji must shift by
one unit between currencies, in both heal and exact-verify directions)
and book-offset ordering. The cmd/server/tests integration harness
failures under docker (library folder 400 during setup) reproduce on
the pre-change tree and are unrelated.
2026-09-26 20:18:48 -04:00
John O'Keefe aec226af1a feat(sync): server-side position authority — verify/heal progress anchors
Progress submissions now carry (percentage, context_text, epubcfi) and
the server becomes the position authority:

- VerifyProgressAnchor resolves the submitted standard CFI against the
  book's own XHTML, extracts the text at the anchor, and cross-checks it
  with the submitted context_text. A mismatch heals the anchor by text
  search (percentage disambiguates repeats) instead of storing a bad
  position.
- The anchor's block element is derived as a cssSelector plus a block-
  relative character offset, and served on progress GET alongside the
  anchor document's href — readium-native handles that let clients
  re-open a book without parsing CFIs themselves.
- context_text-only submissions (no CFI — the dumb-client tier) are
  anchored structurally from the context text.

Motivation: cross-client progress sync (web foliate CFIs, KOReader CRE
xpointers, readium-native apps) previously trusted each client's own
locator math; the app's EPUB restore drifted ±pages because readium's
paginator does not lay out far-from-viewport columns and the foliate-
ported CFI walk ran against readium's mutated WebView DOM. Server-side
verification heals both classes at ingest.
2026-09-26 14:43:34 -04:00
John O'Keefe 1c1f5cf1bb feat(version): bake release tag into binary; About card and /health field
Release / build-and-push (push) Successful in 3m1s
The version tag previously existed only in git and the Docker image tag —
the running app had no way to report what build it was. Version is now
injected at build time via -ldflags into internal/version.Version (defaults
to "dev" for local builds), passed by the release workflow as the
APP_VERSION Docker build arg from the pushed tag.

Surfaced in Admin → Settings → About (extensible card for future rows like
disk space) and in the /health JSON response, so deployments can be
verified with curl alone.
2026-09-21 20:21:43 -04:00
130 changed files with 7866 additions and 2475 deletions

No files matched your search

+3
View File
@@ -56,6 +56,9 @@ jobs:
context: .
file: ./Dockerfile
push: true
# Bake the version into the binary (Admin → Settings → About).
build-args: |
APP_VERSION=${{ env.TAG }}
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
# pin or roll back by setting IMAGE_TAG in .env.
+3
View File
@@ -79,3 +79,6 @@ web/static/vendor/
VALID_TOKEN
fresh_token.txt
session-*.md
# Local prod-export scratch: personal annotation/reading data, never commit
/export/
+8 -1
View File
@@ -37,10 +37,17 @@ RUN cd templates && templ generate
RUN npm run build:css:prod
RUN npm run build:ts
# App version baked into the binary (shown in Admin → Settings → About).
# Declared after `COPY . .` so a version bump only invalidates this layer.
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
ARG APP_VERSION=dev
# Build Go binary (cached unless Go files or generated code changes)
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
-o main ./cmd/server
# Materialize the Go module cache into an image layer so the test-runner
# stage can restore it. GOMODCACHE is /go/pkg/mod; cache mounts elsewhere are
+1 -1
View File
@@ -27,7 +27,7 @@ docs: |-
- `id` (string): Media item ID
**Request Body:**
- `content` (string): Note content (required, 1-10000 chars)
- `content` (string): Note content (required, 1-100000 chars)
- `position` (string): Optional position reference (max 100 chars)
**Response:**
+1 -1
View File
@@ -28,7 +28,7 @@ docs: |-
- `noteId` (string): Note ID
**Request Body:**
- `content` (string): Updated note content (required, 1-10000 chars)
- `content` (string): Updated note content (required, 1-100000 chars)
- `position` (string): Updated position reference (optional, max 100 chars)
**Response:**
+18
View File
@@ -10,6 +10,7 @@ import (
"bookhoard/internal/router"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/templates"
"context"
"log"
"time"
@@ -56,6 +57,20 @@ func main() {
if err := registry.Load(ctx); err != nil {
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
}
// Public signup surfaces follow the registration_enabled flag, with the
// first-user exception: an unconfigured server (zero admins) keeps its
// signup links and registration route reachable for bootstrapping.
templates.SetSignupVisibility(func() bool {
if registry.RegistrationEnabled() {
return true
}
count, err := queries.CountAdmins(context.Background())
if err != nil {
return true // fail open, matching setupstatus
}
return count == 0
})
// Wire the registry into the package-level password validator so live
// rule changes apply to the echo struct-tag validator and ValidatePassword.
middleware.SetDefaultPasswordSettings(registry)
@@ -121,6 +136,9 @@ func main() {
// Create library service
libraryService := services.NewLibraryService(queries)
// The progress service verifies submitted anchors against the book
// itself — it needs to resolve library-relative file paths.
progressService.SetMediaPathResolver(libraryService)
// Sync Go AllowedExtensions into DB so API clients see correct extensions
libraryService.SyncAllowedExtensions(context.Background())
@@ -140,7 +140,6 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
body := readBody(resp)
// Verify reading direction badge is displayed
assert.Contains(t, body, "📖", "Should display reading direction icon")
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
})
@@ -263,7 +262,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
assert.Contains(t, body, "📚", "Should display story arc icon")
// The redesigned badge renders the arc NAME in a text badge
// (no 📚 icon) — assert the name.
assert.Contains(t, body, "The Dark Phoenix Saga", "Should display story arc name")
})
@@ -508,7 +508,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
// Verify all manga-specific fields are displayed
// The redesigned badge (07578e0) renders the direction text chip
// without the 📖 emoji — assert the chip text.
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
assert.Contains(t, body, "Teen", "Should display age rating")
assert.Contains(t, body, "8.5 / 10", "Should display community rating")
@@ -619,9 +620,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
assert.Contains(t, body, "Everyone", "Should display age rating")
assert.Contains(t, body, "goodreads.com", "Should display web URL")
// Verify comic-specific fields are NOT displayed
assert.NotContains(t, body, "Manga Type", "Should not display manga type for ebook")
assert.NotContains(t, body, "Scan Info", "Should not display scan info for ebook")
// Verify comic-specific fields are NOT displayed. The page body
// always contains the admin edit modal's "Manga Type"/"Scan Info"
// LABELS — assert the metadata VALUES (what the display block
// renders) are absent instead.
assert.NotContains(t, body, "yes and right to left", "Should not display manga type value for ebook")
assert.NotContains(t, body, "B&W", "Should not display B&W badge for ebook")
})
@@ -682,9 +685,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
// Page should still render successfully
assert.Contains(t, body, "Minimal Comic", "Should display title")
// Use "Community Rating:" with colon to avoid matching the HTML comment
// The admin edit modal always carries "Manga Type" as a form
// LABEL — the display block would render the capitalized VALUE,
// which is absent here.
assert.NotContains(t, body, "Community Rating:", "Should not show community rating when not set")
assert.NotContains(t, body, "Manga Type", "Should not show manga type when unknown")
assert.NotContains(t, body, "yes and right to left", "Should not show a manga type value when unknown")
})
}
+275 -8
View File
@@ -69,15 +69,16 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
}
// Verify all system collections exist
assert.Contains(s.T(), sectionMap, "continue-reading")
assert.Contains(s.T(), sectionMap, "recently-added")
assert.Contains(s.T(), sectionMap, "recently-read")
assert.Contains(s.T(), sectionMap, "not-started")
assert.Contains(s.T(), sectionMap, "continue-series")
// Verify all system collections exist (names are the section ids;
// the canonical seed names are Title-case).
assert.Contains(s.T(), sectionMap, "Continue Reading")
assert.Contains(s.T(), sectionMap, "Recently Added")
assert.Contains(s.T(), sectionMap, "Recently Read")
assert.Contains(s.T(), sectionMap, "Not Started")
assert.Contains(s.T(), sectionMap, "Continue Series")
// Verify continue-reading is a system collection
continueReading := sectionMap["continue-reading"]
continueReading := sectionMap["Continue Reading"]
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
assert.Equal(s.T(), "📖", continueReading["icon"], "icon should match")
}
@@ -85,6 +86,8 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
token := s.setup.Token
// No library_id = the All Libraries view: the endpoint aggregates
// sections across every library (200), it does not 400.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -95,7 +98,14 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(s.T(), err)
sections, ok := response["sections"].([]interface{})
require.True(s.T(), ok, "sections should be an array")
assert.NotEmpty(s.T(), sections, "All Libraries view should aggregate sections")
}
func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
@@ -258,6 +268,263 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
}
}
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_GlobalScope() {
token := s.setup.Token
// Absent library_id (JSON null after omit) = the global All Libraries
// scope: stored in user_dashboard_preferences_global, one row per user.
reqBody := map[string]interface{}{
"hidden_collections": []string{"not-started"},
"collection_order": []string{"continue-reading", "recently-added"},
"items_per_section": 15,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Second global save with different values: must UPDATE the same row
// (the old ON CONFLICT (user_id, library_id) never fired for NULLs and
// would have minted a duplicate per save).
reqBody["items_per_section"] = 25
body, _ = json.Marshal(reqBody)
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Reading prefs without library_id returns the global row (not some
// arbitrary library's).
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var prefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&prefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(25), prefs["items_per_section"], "global row should carry the second save")
}
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDRejected() {
token := s.setup.Token
// Empty string is a LOST value (a client bug), never a scope — must
// stay a loud 400 rather than silently writing the global scope.
reqBody := map[string]interface{}{
"library_id": "",
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 20,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() {
token := s.setup.Token
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Test Library", false)
// Global scope: 15.
globalBody, _ := json.Marshal(map[string]interface{}{
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 15,
})
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(globalBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Per-library scope: 30. Must not bleed into global or vice versa.
libBody, _ := json.Marshal(map[string]interface{}{
"library_id": libraryID,
"hidden_collections": []string{},
"collection_order": []string{},
"items_per_section": 30,
})
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
// Per-library read.
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var libPrefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&libPrefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(30), libPrefs["items_per_section"], "per-library scope should read its own row")
// Global read.
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var globalPrefs map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&globalPrefs)
require.NoError(s.T(), err)
assert.Equal(s.T(), float64(15), globalPrefs["items_per_section"], "global scope should stay independent")
}
// TestDeleteSystemCollection_TombstonesAndRestores pins the system-
// collection permanence contract: DELETE hides the section everywhere
// (tombstone) and restore brings back the EXACT SAME row (id stable),
// never a fresh one.
func newHTTPClient() *http.Client { return &http.Client{} }
func (s *DashboardIntegrationTestSuite) TestDeleteSystemCollection_TombstonesAndRestores() {
token := s.setup.Token
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false)
collectionID := func(sectionID string) string {
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
var response map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
for _, sec := range response["sections"].([]interface{}) {
section := sec.(map[string]interface{})
if section["id"] == sectionID {
return section["collection_id"].(string)
}
}
return ""
}
hasSection := func(sectionID string) bool {
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
var response map[string]interface{}
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
for _, sec := range response["sections"].([]interface{}) {
if sec.(map[string]interface{})["id"] == sectionID {
return true
}
}
return false
}
idBefore := collectionID("Continue Reading")
require.NotEmpty(s.T(), idBefore, "Continue Reading should exist")
// DELETE a system collection = tombstone (204, behaves like a delete).
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+idBefore, nil)
delReq.Header.Set("Authorization", "Bearer "+token)
delResp, err := newHTTPClient().Do(delReq)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
// Invisible on the dashboard.
assert.False(s.T(), hasSection("Continue Reading"), "tombstoned section must vanish")
// Restore brings back the EXACT SAME row.
restoreBody, _ := json.Marshal(map[string]string{"collection_name": "Continue Reading"})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(restoreBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
restoreResp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(restoreResp.Body)
require.Equal(s.T(), http.StatusOK, restoreResp.StatusCode)
idAfter := collectionID("Continue Reading")
assert.Equal(s.T(), idBefore, idAfter, "restore must resurrect the SAME row id")
assert.True(s.T(), hasSection("Continue Reading"))
}
// TestDeleteUserCollection_HardDelete pins the user-collection contract:
// user collections hard-delete exactly as before (no tombstone, no restore).
func (s *DashboardIntegrationTestSuite) TestDeleteUserCollection_HardDelete() {
token := s.setup.Token
createBody, _ := json.Marshal(map[string]interface{}{"name": "Test User Shelf", "type": "ebooks"})
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/collections", bytes.NewBuffer(createBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
createResp, err := newHTTPClient().Do(req)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(createResp.Body)
require.Equal(s.T(), http.StatusCreated, createResp.StatusCode)
var created map[string]interface{}
require.NoError(s.T(), json.NewDecoder(createResp.Body).Decode(&created))
collectionID := created["id"].(string)
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+collectionID, nil)
delReq.Header.Set("Authorization", "Bearer "+token)
delResp, err := newHTTPClient().Do(delReq)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
listReq, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/collections", nil)
listReq.Header.Set("Authorization", "Bearer "+token)
listResp, err := newHTTPClient().Do(listReq)
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(listResp.Body)
var list struct {
Collections []map[string]interface{} `json:"collections"`
}
require.NoError(s.T(), json.NewDecoder(listResp.Body).Decode(&list))
for _, c := range list.Collections {
assert.NotEqual(s.T(), collectionID, c["id"], "hard-deleted user collection must be gone")
}
}
func TestDashboardIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(DashboardIntegrationTestSuite))
}
+1 -1
View File
@@ -512,7 +512,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
defer setup.Server.Close()
// Create device for user 1
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-device")
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-test-device")
// Create a second user with different credentials
ctx := context.Background()
+21 -11
View File
@@ -47,7 +47,7 @@ func TestSavedFilters(t *testing.T) {
t.Run("POST /api/saved-filters creates filter", func(t *testing.T) {
reqBody := map[string]interface{}{
"name": "My Sci-Fi Books",
"name": "My Sci-Fi Test Books",
"resource_type": "media-items",
"filters": map[string]string{
"genre_filter": "Science Fiction",
@@ -71,7 +71,7 @@ func TestSavedFilters(t *testing.T) {
var filter map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "My Sci-Fi Books", filter["name"])
assert.Equal(t, "My Sci-Fi Test Books", filter["name"])
assert.Equal(t, "media-items", filter["resource_type"])
assert.NotEmpty(t, filter["id"])
// Filters field will be JSON object (JSONB as raw bytes)
@@ -113,7 +113,7 @@ func TestSavedFilters(t *testing.T) {
t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) {
// Create filter first
createReq := map[string]interface{}{
"name": "Original Name",
"name": "Original Test Name",
"resource_type": "media-items",
"filters": map[string]string{"search": "original"},
}
@@ -134,11 +134,13 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Update filter
updateReq := map[string]interface{}{
"name": "Updated Name",
"name": "Updated Test Name",
"resource_type": "media-items",
"filters": map[string]string{"search": "updated"},
}
@@ -159,7 +161,7 @@ func TestSavedFilters(t *testing.T) {
var updatedFilter map[string]interface{}
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
require.NoError(t, err)
assert.Equal(t, "Updated Name", updatedFilter["name"])
assert.Equal(t, "Updated Test Name", updatedFilter["name"])
})
t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) {
@@ -184,7 +186,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Delete filter
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
@@ -223,7 +227,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Admin user tries to delete regular user's filter
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
@@ -260,7 +266,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Now retrieve the filter by ID
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
@@ -324,7 +332,7 @@ func TestSavedFilters(t *testing.T) {
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
// Create filter with admin user (setup.Token)
reqBody := map[string]interface{}{
"name": "Admin Filter",
"name": "Admin Test Filter",
"resource_type": "media-items",
"filters": map[string]string{"search": "test"},
}
@@ -340,7 +348,9 @@ func TestSavedFilters(t *testing.T) {
var createdFilter map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
filterIDRaw, ok := createdFilter["id"]
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
filterID := filterIDRaw.(string)
// Try to access with regular user (setup.RegularToken)
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+19 -3
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -28,7 +30,21 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
})
t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
// Self-created fixture dir: the container's /tmp may be empty, and
// the endpoint lists DIRECTORIES only — seed two so the listing is
// deterministic regardless of environment.
root := t.TempDir()
for _, name := range []string{"alpha", "beta"} {
if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
// A decoy file must NOT appear in the directory listing.
if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/libraries/browse?path="+root, nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
@@ -41,8 +57,8 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
}
err := json.Unmarshal(rec.Body.Bytes(), &data)
assert.NoError(t, err)
assert.Equal(t, "/tmp", data.CurrentPath)
assert.NotEmpty(t, data.Directories)
assert.Equal(t, filepath.Clean(root), data.CurrentPath)
assert.ElementsMatch(t, []string{"alpha", "beta"}, data.Directories)
})
t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
+69 -6
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -151,11 +152,15 @@ func TestMediaItemISBNNormalization(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Unique file_path per case: CreateMediaItem upserts ON
// CONFLICT (library_id, file_path), so a constant path would
// return the FIRST row's ISBN for every later case.
slug := strings.ReplaceAll(strings.ToLower(tc.name), " ", "-")
payload := map[string]interface{}{
"title": fmt.Sprintf("Test Book %s", tc.name),
"isbn": tc.input,
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/" + slug + ".epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -203,7 +208,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
payload := map[string]interface{}{
"title": "Book without ISBN",
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/book-without-isbn.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -228,7 +233,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
"title": "Multi-Hyphen ISBN",
"isbn": "978-0-306-40615-7",
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/multi-hyphen-isbn.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -258,7 +263,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
"title": "Trailing Hyphen ISBN",
"isbn": "978-0-596-00965-2",
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": "/test/trailing-hyphen-isbn.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -292,13 +297,15 @@ func TestMediaItemsPagination(t *testing.T) {
libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
// Create some test media items
// Create some test media items. Unique file_path per item: the create
// endpoint upserts ON CONFLICT (library_id, file_path), so a constant
// path would collapse all five creates onto one row.
for i := 1; i <= 5; i++ {
payload := map[string]interface{}{
"title": fmt.Sprintf("Book %d", i),
"isbn": fmt.Sprintf("978030640615%d", i),
"library_id": libID,
"file_path": "/test/path.epub",
"file_path": fmt.Sprintf("/test/book-%d.epub", i),
"file_size": 1024,
"mime_type": "application/epub+zip",
}
@@ -408,6 +415,62 @@ func TestMediaItemsPagination(t *testing.T) {
})
}
// TestMediaItemCreateDuplicatePath pins the create endpoint's duplicate
// behavior: a POST with a (library_id, file_path) that already exists
// answers 200 OK with the EXISTING item and duplicate=true — never a
// 201 with the old row pretending to be new, and nothing overwritten.
func TestMediaItemCreateDuplicatePath(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
libID := createTestLibrary(t, setup.Server, token, "test-dup-create-lib")
addFolderToLibrary(t, setup, libID, "/app/uploads")
createPayload := func(title string) []byte {
body, _ := json.Marshal(map[string]interface{}{
"title": title,
"author": "First Author",
"isbn": "9780306406157",
"library_id": libID,
"file_path": "/test/dup-target.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
})
return body
}
post := func(body []byte) (*http.Response, []byte) {
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp, raw
}
resp, raw := post(createPayload("Original Title"))
require.Equal(t, http.StatusCreated, resp.StatusCode, "first create should be 201")
var created map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &created))
originalID := created["id"].(string)
resp, raw = post(createPayload("Different Title"))
require.Equal(t, http.StatusOK, resp.StatusCode, "duplicate create should be 200, not a 201 lie")
var dup map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &dup))
assert.Equal(t, true, dup["duplicate"], "response must flag the duplicate")
assert.Equal(t, originalID, dup["item"].(map[string]interface{})["id"].(string),
"duplicate response carries the existing item")
assert.Equal(t, "Original Title", dup["item"].(map[string]interface{})["title"],
"the second create's payload must NOT overwrite the existing row")
}
// TestMediaItemLibraryRequirement tests that media items require a library
func TestMediaItemLibraryRequirement(t *testing.T) {
setup := setupTestServer(t)
@@ -0,0 +1,54 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMediaNoteLengthLimits verifies the note content validator cap
// (internal/handlers/media.go): content up to 100,000 chars is accepted,
// longer content is rejected with 400.
func TestMediaNoteLengthLimits(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
mediaItemID := createTestMediaItemID(t, setup)
postNote := func(content string) int {
payload := map[string]interface{}{
"content": content,
"position": "page:1",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+mediaItemID+"/notes", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
return resp.StatusCode
}
t.Run("50k-char note accepted", func(t *testing.T) {
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("a", 50000)))
})
t.Run("100k-char note accepted at boundary", func(t *testing.T) {
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("b", 100000)))
})
t.Run("over-limit note rejected", func(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, postNote(strings.Repeat("c", 100001)))
})
}
+9 -18
View File
@@ -16,8 +16,7 @@ func TestProcessingIssuesListAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Auth Library", "manga")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
@@ -55,8 +54,7 @@ func TestProcessingIssuesListResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Structure Library", "comics")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Structure Library", "comics")
t.Run("Returns JSON array for empty library", func(t *testing.T) {
token := setup.Token
@@ -163,8 +161,7 @@ func TestProcessingIssueStatsAuthentication(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Auth Library", "manga")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Auth Library", "manga")
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
@@ -202,8 +199,7 @@ func TestProcessingIssueStatsResponseStructure(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Structure Library", "comics")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Structure Library", "comics")
t.Run("Returns correct structure for empty library", func(t *testing.T) {
token := setup.Token
@@ -343,11 +339,9 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
setup := setupTestServer(t)
// Create two different libraries with different types
deviceSetup1 := setupDeviceTest(t)
library1ID := deviceSetup1.CreateLibrary(t, "Isolation Test Library 1", "manga")
library1ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 1", "manga")
deviceSetup2 := setupDeviceTest(t)
library2ID := deviceSetup2.CreateLibrary(t, "Isolation Test Library 2", "comics")
library2ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 2", "comics")
token := setup.Token
@@ -431,8 +425,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
for _, lt := range libraryTypes {
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -448,8 +441,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
})
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -474,8 +466,7 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
setup := setupTestServer(t)
// Create a test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Token Library", "ebooks")
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Token Library", "ebooks")
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
+177
View File
@@ -656,3 +656,180 @@ func TestProgressWeb_EdgeCases(t *testing.T) {
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
})
}
// TestDeletedUserTokenRejected pins the ghost-session guard: a
// signature-valid JWT for a user that has been DELETED must stop
// authenticating (401 user_not_found), not serve a phantom session.
// Before the guard, deleted users kept full API access until token
// expiry (observed live on hardware 2026-10-09).
func TestDeletedUserTokenRejected(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
// Create a dedicated user, then log in as it to get a fresh token.
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/register", map[string]interface{}{
"email": "ghost-test@tests.bookhoard.internal",
"username": "ghosttest",
"password": "Ghost@Pass123!",
}, setup.Token)
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusCreated {
t.Skipf("registration unavailable (status %d)", createResp.StatusCode)
}
loginResp := doReq(t, "POST", setup.Server.URL+"/api/auth/login", map[string]interface{}{
"login": "ghost-test@tests.bookhoard.internal",
"password": "Ghost@Pass123!",
}, "")
defer loginResp.Body.Close()
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginBody struct {
AccessToken string `json:"access_token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
require.NotEmpty(t, loginBody.AccessToken)
ghostToken := loginBody.AccessToken
// Sanity: the token works while the user exists (user-scoped endpoint
// a freshly-registered regular user can access).
okResp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
defer okResp.Body.Close()
require.Equal(t, http.StatusOK, okResp.StatusCode, "token must work while the user exists")
// Delete the user directly via the DB for determinism (same query the
// suite's own cleanup uses).
ghostID := ""
users, err := setup.DB.ListUsers(ctx)
require.NoError(t, err)
for _, u := range users {
if u.Email == "ghost-test@tests.bookhoard.internal" {
ghostID = uuid.UUID(u.ID.Bytes).String()
require.NoError(t, setup.DB.DeleteUser(ctx, u.ID))
}
}
require.NotEmpty(t, ghostID, "ghost user row must exist before deletion")
// The same token must now be rejected with 401 user_not_found.
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "ghost token must be rejected")
var errBody map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
assert.Contains(t, errBody["message"], "user_not_found")
}
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
// percentage-only write that DECLARES no locator (ClearLocator) must clear
// any inherited stale anchor instead of re-pairing the new percentage with
// the previous writer's CFI/context — the incoherent row shape that fed the
// client-side far-anchor restore floor. Request-carried fields always win.
func TestProgressWeb_LocatorCoherence(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
userID := getTestUserID(t, setup.DB)
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
mediaUUID, _ := uuid.Parse(mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "web",
Percentage: pFloat64(0.3),
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
ContextText: pStr("some anchor context sentence"),
DeviceType: "web",
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
return mediaUUID
}
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "kobo",
Percentage: pFloat64(0.6),
DeviceType: "kobo",
ClearLocator: true,
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
assert.False(t, progress.ContextText.Valid, "stale context cleared")
assert.Equal(t, "kobo", progress.LastSyncSource.String)
})
t.Run("request-carried context survives the clear", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "koreader",
Percentage: pFloat64(0.7),
ContextText: pStr("a brand new context sentence"),
DeviceType: "koreader",
ClearLocator: true,
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
// The verify gate is inert in the test harness (no media path
// resolver wired), so no anchor can be minted here; the rule
// under test is that the STALE one is gone.
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
})
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID := seedAnchor(t, mediaItemID)
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
// path (field absent = inherit): the anchor must survive.
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
Source: "queue",
Percentage: pFloat64(0.4),
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
})
}
+5 -5
View File
@@ -384,12 +384,12 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2")
tests := []struct {
name string
query string
libraryID string
expectedCount int
name string
query string
libraryID string
expectedCount int
expectedStatus int
shouldContain string // Comma-separated list of book IDs to check
shouldContain string // Comma-separated list of book IDs to check
}{
{
name: "no filter - both test books present",
+7 -2
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -34,15 +35,19 @@ func TestUnifiedSearch(t *testing.T) {
_ = folderResp.Body.Close()
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
// Helper to create book with fields
// Helper to create book with fields. Unique file_path per book: the
// create endpoint answers 200/duplicate for a repeated
// (library_id, file_path), and the old shared constant made the 2nd/3rd
// creates no-ops anyway.
createBook := func(title, author, genre string, year int, hasCover bool) {
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
bookReq := map[string]interface{}{
"library_id": libraryID,
"title": title,
"author": author,
"genre": genre,
"copyright_year": year,
"file_path": "/tmp/test.epub",
"file_path": "/tmp/" + slug + ".epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
+36 -13
View File
@@ -32,6 +32,11 @@ func (s *SeriesIntegrationTestSuite) TearDownSuite() {
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
// Ground truth: library_id is OPTIONAL on /api/series by design —
// GetDistinctSeries matches (narg IS NULL OR library_id = narg), so a
// missing library_id aggregates series across all libraries. The
// former "library_id required → 400" expectation asserted validation
// the shipped query never had.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -42,12 +47,13 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]string
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "library_id required", body["error"])
assert.Contains(s.T(), body, "series", "response carries the series array")
assert.Contains(s.T(), body, "total")
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
@@ -159,7 +165,14 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NameOnlyContract() {
// Ground truth: /api/series/books requires only `name` — the app
// client sends name-only (BookhoardApi.kt) and the web SSR page too
// (frontend.go). The query is intentionally library-agnostic (it
// matches the series name across libraries; library_id, if sent, is
// ignored — nothing validates it). The former "library_id required /
// bad uuid → 400" expectations asserted validation that never
// shipped.
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -170,12 +183,14 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var body map[string]string
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "library_id required", body["error"])
assert.Equal(s.T(), "Test Series", body["name"])
assert.Contains(s.T(), body, "books", "response carries the books array")
assert.Contains(s.T(), body, "total")
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
@@ -199,6 +214,8 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
// Same name-only contract: an unparseable library_id is ignored, not
// validated (nothing in the shipped pipeline ever reads it here).
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
req.Header.Set("Authorization", "Bearer "+s.token)
@@ -209,7 +226,7 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
@@ -349,11 +366,17 @@ func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
sectionIDs[section["id"].(string)] = true
}
assert.Contains(s.T(), sectionIDs, "continue-series", "dashboard should include continue-series section")
assert.Contains(s.T(), sectionIDs, "continue-reading")
assert.Contains(s.T(), sectionIDs, "recently-added")
assert.Contains(s.T(), sectionIDs, "recently-read")
assert.Contains(s.T(), sectionIDs, "not-started")
// Ground truth: the /sections contract keys a section's identity on
// the collection NAME — BuildSections sets ID = CollectionName, and
// the web/app reorder + hidden-collection preferences persist those
// same strings (data-collection-id). The kebab "query_type"
// ("continue-series" etc.) is the STORAGE key for system collections
// and is not exposed on this endpoint.
assert.Contains(s.T(), sectionIDs, "Continue Series", "dashboard should include Continue Series")
assert.Contains(s.T(), sectionIDs, "Continue Reading")
assert.Contains(s.T(), sectionIDs, "Recently Added")
assert.Contains(s.T(), sectionIDs, "Recently Read")
assert.Contains(s.T(), sectionIDs, "Not Started")
}
func TestSeriesIntegrationTestSuite(t *testing.T) {
+7 -2
View File
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -34,14 +35,18 @@ func TestTagsFilter(t *testing.T) {
_ = folderResp.Body.Close()
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
// Helper to create book with tags
// Helper to create book with tags. The file_path must be UNIQUE per
// book: CreateMediaItem upserts ON CONFLICT (library_id, file_path),
// so a constant path collapses every create onto the first row and
// the later books' tags never land.
createBook := func(title, author string, tags []string) {
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
bookReq := map[string]interface{}{
"library_id": libraryID,
"title": title,
"author": author,
"tags": tags,
"file_path": "/tmp/test.epub",
"file_path": "/tmp/" + slug + ".epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
+12
View File
@@ -109,6 +109,18 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
require.NoError(t, err)
libraryID := libResponse["id"].(string)
// Every library a test creates must not outlive that test — delete
// it on cleanup so runs leave no residue in the dev database.
t.Cleanup(func() {
delHTTP, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/libraries/%s", ts.URL, libraryID), nil)
delHTTP.Header.Set("Authorization", "Bearer "+token)
delResp, err := client.Do(delHTTP)
if err == nil {
_, _ = io.Copy(io.Discard, delResp.Body)
_ = delResp.Body.Close()
}
})
if withFolder {
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
+141 -50
View File
@@ -77,21 +77,21 @@ type DeviceTestData struct {
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
type TestServerSetup struct {
Server *httptest.Server
DB *database.Queries
DBPool *pgxpool.Pool
Config *config.Config
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService
Server *httptest.Server
DB *database.Queries
DBPool *pgxpool.Pool
Config *config.Config
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService
AnnotationService *wsync.AnnotationService
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
Token string
RegularToken string
mu sync.Mutex
closed bool
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
Token string
RegularToken string
mu sync.Mutex
closed bool
}
// Close cleans up all resources in the correct order
@@ -275,6 +275,10 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
// createDefaultCollectionsForUser creates the 4 default system collections for a user
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
ctx := context.Background()
// Canonical Title-case names — the same list the registration seed
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
// A stale kebab-case copy lived here once and duplicated every
// system collection in the shared dev DB.
defaultCollections := []struct {
Name string
Description string
@@ -283,14 +287,34 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
QueryType string
Priority int32
}{
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
{"continue-series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
{"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
}
for _, col := range defaultCollections {
// Idempotent by the STABLE identity (user + query_type): existing
// rows (renamed, tombstoned) heal to canonical metadata in place;
// insert only when the query type has no row. A blind insert here
// hit UNIQUE(user_id, name) on every preserved-admin rerun.
existing, gerr := db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: userID,
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
})
if gerr == nil {
_, uerr := db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: existing.ID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
})
require.NoError(t, uerr, "Should restore default collection: "+col.Name)
continue
}
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID,
Name: col.Name,
@@ -515,39 +539,43 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Setup routes using router package
routerConfig := &router.Config{
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
ProcessingIssuesHandler: processingIssuesHandler,
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
JobsHandler: jobsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
CollectionHandler: collectionHandler,
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
JobsHandler: jobsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
// RegisterRoutes dereferences Settings for the auth rate limit;
// the main binary wires database.NewSettingsRegistry(queries) —
// the test harness must too or route registration nil-panics.
Settings: database.NewSettingsRegistry(queries),
// main.go:246 passes the shared LibraryService here; omitting it
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
LibraryService: libraryService,
}
router.RegisterRoutes(routerConfig)
@@ -623,9 +651,41 @@ func setupTestServer(t *testing.T) *TestServerSetup {
require.NoError(t, cerr, "Failed to create admin test user")
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse admin user UUID")
// Default collections are created exactly once per user; the
// preserved dev admin already has its set from the first run.
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
}
// The canonical system-collection set is ensured on EVERY run: the
// helper is idempotent by (user, query_type) and heals tombstoned or
// renamed rows, so suite runs can't accumulate naming-drift copies.
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
// Delete test-created COLLECTIONS (names containing "test"): without
// this, every suite run leaves its collections behind and the next
// run's creates die on UNIQUE(user_id, name) — the "collections
// 500-panic" rot. System collections are skipped (permanent rows).
// Same convention as the library pass: names containing "test".
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
}
}
}
// Same passes for test DEVICES (identifier/name containing "test")
// and test SAVED FILTERS (name containing "test"): both accumulated
// across runs and poisoned later runs (duplicate identifiers, 409s,
// non-empty "initially empty" assertions).
_, derr := dbPool.Exec(ctx,
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
adminUUID)
if derr != nil {
t.Logf("device cleanup failed: %v", derr)
}
_, ferr := dbPool.Exec(ctx,
"DELETE FROM saved_filters WHERE user_id = $1 AND name ILIKE '%test%'",
adminUUID)
if ferr != nil {
t.Logf("saved-filter cleanup failed: %v", ferr)
}
// Create fresh regular test user
@@ -676,6 +736,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
}
}
// Same pass for test-created COLLECTIONS (the "collections
// 500-panic" rot: they lingered forever and poisoned later runs).
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
}
}
}
if err := setup.Close(); err != nil {
t.Errorf("Failed to cleanup test server: %v", err)
}
@@ -723,6 +794,26 @@ func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
return userUUID
}
// createLibraryDirect inserts a library owned by the given user WITHOUT a
// nested test-server setup: a second setupTestServer bootstraps away the
// first setup's users, and the ghost-session guard rightly rejects the
// outer test's tokens from then on.
func createLibraryDirect(t *testing.T, db *database.Queries, ownerID uuid.UUID, name, libraryType string) string {
ctx := context.Background()
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, libraryType)
require.NoError(t, err, "Should find library type")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "Test library description", Valid: true},
LibraryTypeID: libraryTypeRow.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(ownerID), Valid: true},
})
require.NoError(t, err, "Should create library")
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
require.NoError(t, err, "Should parse library ID")
return libraryUUID.String()
}
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
ctx := context.Background()
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
+21
View File
@@ -68,6 +68,7 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
('registration_enabled', 'true', 'Allow users to create their own accounts (admins can always create accounts)', 'bool', NULL, NULL, FALSE, 'security'),
-- security / auth (restart required)
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
@@ -1134,9 +1135,15 @@ CREATE TABLE IF NOT EXISTS collections (
priority INT DEFAULT 100,
is_system_collection BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE(user_id, name)
);
-- System collections (the 5 dashboard sections) are permanent: "delete"
-- tombstones the row (deleted_at) and restore un-tombstones it in place —
-- the row id (and everything referencing it) never churn.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries
@@ -1159,6 +1166,20 @@ CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
-- Index for fast lookups
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
-- Global (all-libraries) dashboard preferences: exactly one row per user.
-- Backs the dashboard's "All Libraries" view (library_id NULL convention
-- rejected: the upsert's ON CONFLICT cannot dedupe NULLs and intent would
-- be invisible — a dedicated table makes the scope structural). System
-- and user collections are user-scoped, so collection_order/
-- hidden_collections IDs are library-independent and valid in this scope.
CREATE TABLE IF NOT EXISTS user_dashboard_preferences_global (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
hidden_collections TEXT[] DEFAULT '{}',
collection_order TEXT[] DEFAULT '{}',
items_per_section INT DEFAULT 20,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create collection_items table (which books belong to each collection)
CREATE TABLE IF NOT EXISTS collection_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+2 -1
View File
@@ -15,7 +15,8 @@ services:
# Make other volumes as needed
- ./uploads:/app/uploads
ports:
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
+40 -108
View File
@@ -394,6 +394,11 @@ Authorization: Bearer <token>
## Reading Progress
Full field reference: [api/progress/](api/progress/) — and read the
[Position Contract](api/progress/position-contract.md) (verification,
healing, the OPF spine numbering hazard, offset currencies) before
writing a client.
### Get Reading Progress
```http
@@ -405,57 +410,56 @@ Authorization: Bearer <token>
```json
{
"id": "uuid",
"media_item_id": "uuid",
"user_id": "uuid",
"current_page": 45,
"total_pages": 200,
"percentage": 0.225,
"character_offset": 15432,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3,
"chapter_progress": 0.5,
"last_read_at": "2026-01-31T10:00:00Z",
"percentage": 0.045,
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
"context_text": "from day to day, but there was none in which ...",
"character_offset": 16375,
"current_page": null,
"total_pages": null,
"chapter": null,
"chapter_progress": null,
"format_group": "reflowable",
"viewport_y": 0.12,
"zoom_level": 1.0
"total_characters": 592216,
"chapter_count": 1,
"last_read_at": "2026-09-26T14:52:34Z",
"last_sync_source": "koreader",
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
"anchor_href": "1984.xhtml",
"char_offset": 598
}
```
`css_selector`, `anchor_href` and `char_offset` are server-derived
restore handles, served for convertible reflowable books with a
resolvable anchor. `char_offset` is UTF-16 code units within the anchor
block's text; `character_offset` is a book-wide rune count. Resolve the
document by `anchor_href`, never by the CFI's spine step (OPF numbering
includes `linear="no"` items).
### Update Reading Progress
```http
PUT /api/media-items/{media_id}/progress
Authorization: Bearer <token>
Content-Type: application/json
{
"source": "web",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "Mozilla/5.0..."
}
}
```
**Response** (200):
```json
{
"sync_status": "success",
"progress_updated": true,
"devices_notified": ["device-1", "device-2"],
"broadcast": true
"percentage": 0.0415,
"context_text": "was at war with one of these Powers it was generally a",
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
}
```
All fields are optional. The server verifies the submission against the
book and heals it on mismatch; the response is the stored row after
verification. A `percentage` below 0.005 is ignored while stored
progress exceeds 0.01 (first-page anti-clobber).
### Delete Reading Progress
```http
@@ -1297,82 +1301,10 @@ Authorization: Bearer <device_token>
## Universal Progress
### Get Universal Progress
```http
GET /api/progress/{book_uuid}
Authorization: Bearer <token>
```
**Response** (200):
```json
{
"book_id": "book-uuid",
"format_group": "reflowable",
"universal_progress": 0.45678,
"location_references": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"chapter_progress": 0.234,
"viewport_y": 0.12
},
"device_progress": {
"koreader": {
"percentage": 0.45678,
"last_sync": "2026-01-30T20:00:00Z"
},
"kobo": {
"percentage": 45.6,
"last_sync": "2026-01-30T19:55:00Z"
},
"web": {
"display_page": 89,
"total_pages": 200,
"last_sync": "2026-01-30T20:05:00Z"
}
},
"annotations": {
"highlights": [...],
"notes": [...],
"bookmarks": [...]
},
"conflicts": [
{
"id": "conflict-uuid",
"type": "progress",
"resolved": false,
"sources": ["koreader", "kobo"]
}
]
}
```
### Update Universal Progress
```http
POST /api/progress/{book_uuid}
Authorization: Bearer <token>
Content-Type: application/json
{
"source": "web|koreader|kobo|mobile",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "..."
}
}
```
Universal progress is the media-item progress — one row per (user,
media item) shared by every device. See [Reading Progress](#reading-progress)
and the [Position Contract](api/progress/position-contract.md). There
are no separate `/api/progress/:id` GET/POST endpoints.
## Conflicts
+7 -3
View File
@@ -117,10 +117,14 @@ See [Media Item Operations](media-items/)
## Reading Progress
See [Progress Tracking](progress/)
See [Progress Tracking](progress/) — in particular the
[Position Contract](progress/position-contract.md) (verification,
healing, the OPF spine numbering hazard, and offset currencies) before
writing a client.
- GET /api/progress/:id - Get universal progress
- POST /api/progress/:id - Update universal progress
- GET /api/media-items/:id/progress - Get reading progress + restore handles
- PUT /api/media-items/:id/progress - Submit reading progress (verified/healed server-side)
- DELETE /api/media-items/:id/progress - Delete reading progress
- GET /api/progress/:id/history - Get progress history
## Notes & Highlights
+42 -25
View File
@@ -1,9 +1,12 @@
# Get Metadata
Get metadata for a book from KOReader device.
Get a book's stored progress and annotations for a KOReader device —
the pull half of the device sync. The reference client calls this after
linking a book via [Resolve Book](resolve_book.md) and navigates to the
returned position.
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
**Auth**: Required (Device authentication)
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
## Path Parameters
@@ -11,41 +14,55 @@ Get metadata for a book from KOReader device.
| --------- | ------------- | -------- | ----------- |
| uuid | string (UUID) | Yes | Book UUID |
## Device Authentication
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
## Request Headers
| Header | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------- |
| X-Device-ID | string | Yes | Device UUID |
| X-Device-Key | string | Yes | Device authentication key |
### Example Request
```http
GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000
X-Device-ID: 550e8400-e29b-41d4-a716-446655440000
X-Device-Key: device-auth-key
GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56
Authorization: Bearer {device_token}
```
## Response (200 OK)
```json
{
"id": "uuid",
"title": "Book Title",
"authors": ["Author Name"],
"path": "/path/to/book.epub",
"file_size": 1234567,
"modified_at": "2026-02-08T10:00:00Z"
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
"title": "1984",
"author": "George Orwell",
"progress": {
"percentage": 0.045,
"koreader_xpointer": "/body/DocFragment[1]/body/p[29]/text().598",
"chapter": null,
"chapter_progress": null,
"page": null,
"total_pages": null
},
"annotations": {
"highlights": []
}
}
```
### Progress Object
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Stored position as a book fraction. |
| `koreader_xpointer` | string | The stored canonical position converted back to a CRE xpointer (UTF-16 `text().N` offset). **The device should navigate to this.** Reflowable books only. |
| `epubcfi` | string | The stored canonical CFI, when the conversion to a CRE xpointer is unavailable. Fallback after `koreader_xpointer`. |
| `character` | int | Book-wide rune offset (internal currency). |
| `chapter`, `chapter_progress` | int, float | Chapter position when known. |
| `page`, `total_pages` | int | Fixed-layout page position — the canonical locator for image-based books (CFI/xpointer are omitted for them). |
`progress` is `null` when the book has no stored progress.
The `annotations` object carries device-format highlights/bookmarks/notes
synced from other clients; its presence depends on annotation sync being
enabled.
## Error Responses
| Code | Description |
| ---- | ---------------------------- |
| Code | Description |
| ---- | ----------- |
| 400 | Invalid book UUID |
| 401 | Device authentication failed |
| 404 | Book or device not found |
| 404 | Book not found |
+58 -34
View File
@@ -1,63 +1,87 @@
# Sync Progress
Sync reading progress from KOReader device.
Push reading progress from a KOReader device.
**Endpoint**: `POST /api/sync/koreader/progress`
**Auth**: Required (Device authentication)
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
## Device Authentication
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
This is the device-native tier of the [Position
Contract](../progress/position-contract.md): the KOReader payload carries
a CRE xpointer and the server converts it to the canonical standard CFI,
verifies it against the submitted `context_text`, and heals it on
mismatch — exactly like every other client.
## Request Body
| Field | Type | Required | Description |
| --------- | ------------- | -------- | ------------------------- |
| device_id | string (UUID) | Yes | Device UUID |
| progress | array | Yes | Array of progress objects |
| Field | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `books` | array | Yes | One book object (the reference client sends a single-element array). |
| `sync_mode`| string | No | `immediate` (default) or `manual`. |
### Progress Object
### Book Object
| Field | Type | Required | Description |
| ----------- | ------- | -------- | ---------------------------------- |
| book | string | Yes | Book identifier (filename or UUID) |
| percent | float | Yes | Progress percentage (0-100) |
| page | integer | No | Current page number |
| total_pages | integer | No | Total pages in document |
| date_read | string | No | ISO 8601 timestamp of last read |
| updated_at | string | Yes | ISO 8601 timestamp |
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). |
| `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. |
| `title` | string | No | Document title. |
| `authors` | array | No | Author names. |
| `percentage` | float | Yes | Position as a fraction of the book (0..1). |
| `context_text` | string | No | Up to 100 whitespace-normalized chars from the current position — enables the server's verification/healing. Strongly recommended. |
| `page` | int | No | Current page (fixed-layout books). |
| `total_pages` | int | No | Page count (fixed-layout books). |
| `epubcfi` | string | Reflowable only | **A CRE xpointer** (`/body/DocFragment[N]/body/...`), not a CFI — the field name is historical. Fixed-layout books must omit it and carry their position in `page`/`total_pages`. |
| `file_path` | string | No | Device-local file path (informational). |
| `device_info` | object | No | `{ koreader_version, device_model }`. |
### Example Request
```http
POST /api/sync/koreader/progress
Authorization: Bearer {device_token}
Content-Type: application/json
```
```json
{
"device_id": "550e8400-e29b-41d4-a716-446655440000",
"progress": [
"books": [
{
"book": "book.epub",
"percent": 75.5,
"page": 150,
"total_pages": 200,
"date_read": "2026-02-08T10:00:00Z",
"updated_at": "2026-02-08T10:00:00Z"
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"title": "1984",
"authors": ["George Orwell"],
"percentage": 0.045,
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim",
"page": 14,
"total_pages": 311,
"epubcfi": "/body/DocFragment[1]/body/p[29]/text().598",
"device_info": {
"koreader_version": "v2026.07.1",
"device_model": "emulator"
}
}
]
],
"sync_mode": "immediate"
}
```
## Response (200 OK)
## Response (202 Accepted)
```json
{
"message": "Progress synced successfully",
"synced_count": 1
"sync_status": "ok",
"books_synced": 1,
"timestamp": "2026-09-26T21:25:09Z"
}
```
Per-book results and any detected sync conflicts are carried in
`book_results` and `conflicts` when present.
## Error Responses
| Code | Description |
| ---- | ---------------------------- |
| Code | Description |
| ---- | ----------- |
| 400 | Invalid request format |
| 401 | Device authentication failed |
| 400 | Invalid request data |
| 404 | Device not found |
| 500 | Database error |
@@ -0,0 +1,35 @@
# Delete Media Item Progress
Delete the stored reading progress for a media item.
**Endpoint**: `DELETE /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ---------------- |
| id | string (UUID) | Yes | Media item UUID |
### Example Request
```http
DELETE /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
## Response (200 OK)
```json
{
"message": "reading progress deleted"
}
```
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid media item id |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,36 +0,0 @@
# Delete Reading Progress
Delete reading progress for a media item.
**Endpoint**: `DELETE /api/media-items/{media_id}/progress`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
DELETE /api/media-items/uuid/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (204 No Content)
Progress deleted successfully.
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -0,0 +1,98 @@
# Get Media Item Progress
Get the stored reading progress for a media item, plus the
server-derived restore handles for reflowable books.
**Endpoint**: `GET /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
See [Position Contract](position-contract.md) for the semantics of every
field — what is verified, what the currencies are, and how clients
should restore.
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
### Example Request
```http
GET /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
## Response (200 OK) — no progress stored
When the item has no progress row, an empty fixed-layout-style stub is
returned (not 404):
```json
{
"current_page": 0,
"total_pages": null
}
```
## Response (200 OK) — progress stored
```json
{
"id": "e8239659-0ed6-42ae-a946-8440d7655b42",
"media_item_id": "774641f9-317b-4087-8e04-53bb4392ae56",
"user_id": "1b64992e-3408-4d84-9e03-e2dc4950e1dd",
"current_page": null,
"total_pages": null,
"last_read_at": "2026-09-26T14:52:34.806446Z",
"percentage": 0.045,
"character_offset": 16375,
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
"chapter": null,
"chapter_progress": null,
"format_group": "reflowable",
"total_characters": 592216,
"chapter_count": 1,
"last_sync_device": "web",
"last_sync_source": "koreader",
"last_sync_timestamp": "2026-09-26T14:52:34.806446Z",
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
"anchor_href": "1984.xhtml",
"char_offset": 598,
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim"
}
```
### Field Reference
Base fields (always present when a row exists):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Position as a fraction of the whole book (0..1). |
| `epubcfi` | string | The stored canonical standard CFI. **Spine steps index the OPF spine as written, including `linear="no"` items — do not resolve them against a readium reading order.** See the [Position Contract](position-contract.md#spine-numbering-hazard--read-this-before-parsing-a-stored-cfi). |
| `context_text` | string | The stored verification context (≤100 whitespace-normalized chars from the anchor). |
| `character_offset` | int | Book-wide rune offset. Internal currency — consistent with `total_characters`. |
| `current_page`, `total_pages` | int | Fixed-layout page position (null for reflowable). |
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction, when known. |
| `format_group` | string | `reflowable`, `fixed_layout`, `comic_archive`, … Gates the restore handles. |
| `total_characters`, `chapter_count` | int | Book metrics, for client-side fraction math. |
| `last_sync_device`, `last_sync_source`, `last_sync_timestamp` | — | Which client last wrote the row. |
Restore handles (conditional — served only for convertible reflowable
books whose stored anchor re-resolves at GET time):
| Field | Type | Description |
| ----- | ---- | ----------- |
| `anchor_href` | string | The spine document containing the anchor (e.g. `1984.xhtml`). **Resolve your resource by this**, never by the CFI's spine step. |
| `css_selector` | string | Body-relative chain to the anchor's block element. |
| `char_offset` | int | Anchor offset within the block's concatenated text, in UTF-16 code units. |
| `epubcfi` | string | Re-served (possibly healed) canonical CFI — present whenever re-verification produced one. |
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid media item id |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,52 +0,0 @@
# Get Reading Progress
Retrieve reading progress for a specific media item.
**Endpoint**: `GET /api/media-items/{media_id}/progress`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
GET /api/media-items/uuid/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"user_id": "uuid",
"current_page": 45,
"total_pages": 200,
"percentage": 0.225,
"character_offset": 15432,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3,
"chapter_progress": 0.5,
"last_read_at": "2026-01-31T10:00:00Z",
"format_group": "reflowable",
"viewport_y": 0.12,
"zoom_level": 1.0
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -1,50 +0,0 @@
# Get Universal Progress
Get universal (device-agnostic) reading progress for a media item.
**Endpoint**: `GET /api/progress/:id`
**Auth**: Required
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes | Bearer token |
### Example Request
```http
GET /api/progress/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"percentage": 75.5,
"position": 1234,
"page": 150,
"total_pages": 200,
"finished": false,
"updated_at": "2026-02-08T10:00:00Z",
"device": {
"id": "device-uuid",
"name": "My Kobo"
}
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -0,0 +1,135 @@
# Position Contract
How reading positions are represented, submitted, verified, and restored
across clients. This is the contract every client speaks — readium-based
apps, web (foliate), KOReader, and Kobo devices alike.
**The server is the position authority.** Every submission is
independently verified against the book itself before it is stored; a
client whose locator math is wrong cannot poison the stored position.
In exchange, the server hands back handles clients can apply directly,
so they never have to parse or trust CFIs themselves.
## The three-tier submission
Clients submit what their renderer can reliably observe. All three tiers
are accepted on `PUT /api/media-items/:id/progress` (and on the KOReader
device endpoints):
| Tier | Field | Currency | Notes |
| ------ | -------------- | ----------------------------------------------------- | ----- |
| 1 | `percentage` | Float 0..1 of the whole book | Universal. The only field every client can supply. |
| 2 | `context_text` | Up to 100 chars, whitespace-normalized, starting at the anchor | The verification anchor. The server extracts the text at the submitted structural anchor and compares. |
| 3 | `epubcfi` | Standard wrapped CFI, terminals in UTF-16 code units | Optional structural anchor. KOReader submits a CRE xpointer and the server converts it. |
Tier 2 is what makes the system self-correcting: percentages alone
cannot distinguish "the reader's locator is right" from "the reader
silently reported wherever it is currently scrolled".
## Verification and healing (ingest side)
On every progress save for a convertible reflowable book that carries a
`context_text`, the server:
1. Parses the submitted `epubcfi` (if any) and resolves it against the
book's own XHTML.
2. Extracts the text at the resolved anchor and cross-checks it with the
submitted `context_text`.
3. On a mismatch — or an unresolvable anchor, or no anchor at all —
heals the position by text search, using the submitted `percentage`
to disambiguate repeated phrases. The healed CFI and a recomputed
percentage are stored in place of the submitted ones.
4. Refreshes the book-wide `character_offset` column from the verified
anchor on every verified save, so it never goes stale behind the
anchor.
If verification fails outright (book file unreadable, context not found
and percentage cannot disambiguate), the error is logged and the
submission is stored as-is — verification never rejects a save, it only
corrects.
## The restore handles
`GET /api/media-items/:id/progress` serves, alongside the raw stored
fields, the server-derived handles for reflowable books with a
resolvable anchor:
| Field | Currency | Meaning |
| ------------- | ----------------------- | ------- |
| `anchor_href` | — | The spine document the anchor lives in (e.g. `1984.xhtml`). **This is how a client finds the right resource.** |
| `css_selector`| — | Body-relative `tag:nth-child(k)` chain of the anchor's block element (e.g. `body>div:nth-child(4)>p:nth-child(29)`). |
| `char_offset` | UTF-16 code units | Offset of the anchor within the concatenated text of that block. |
| `epubcfi` | UTF-16 terminals | The stored canonical CFI — re-served healed if the GET-time re-verification improved it. |
| `context_text`| — | The stored verification context (≤100 normalized chars from the anchor). |
**Recommended restore sequence for a client:**
1. Open the book and jump to the stored `percentage` (coarse floor).
2. Resolve `anchor_href` against your own spine (an ends-with match on
document hrefs) and open that document if you are not already there.
3. Query `css_selector` in that document, walk its text nodes counting
UTF-16 units to `char_offset`, and scroll that position into view.
4. If the anchor cannot be measured (renderer-specific laziness), the
percentage floor stands.
## SPINE NUMBERING HAZARD — read this before parsing a stored CFI
**`epubcfi` spine steps index the OPF spine AS WRITTEN, including
`linear="no"` items.** Several rendering engines (notably readium)
number their reading order EXCLUDING `linear="no"` items. When a book's
cover (or any other item) is `linear="no"`, the two numberings differ by
a constant offset from that item onward — a client resolving a stored
CFI's spine step against its own numbering lands in the WRONG DOCUMENT.
This is not hypothetical: "1984" epubs commonly have a `linear="no"`
cover, which makes readium spine 0 = OPF spine 1. The server heals such
numbering mismatches at ingest (that is what the context check is for),
but the durable rule for client authors is:
> **Never resolve a stored CFI's spine step yourself.** Resolve the
> document by `anchor_href`, then land with `css_selector` +
> `char_offset`. Treat the CFI as opaque server currency.
## Offset currencies
Two counting systems are in play, and they are deliberately kept apart:
| Quantity | Currency | Why |
| -------- | -------- | --- |
| CFI terminal offsets (`…/1:456`) | UTF-16 code units | The EPUB CFI spec, and what every client observes (JavaScript `.length`). |
| `char_offset` (block-relative handle) | UTF-16 code units | Same reason — clients walk DOM text with JS semantics. |
| KOReader CRE `text().N` offsets | UTF-16 code units | crengine is UCS-16 internally. |
| `character_offset` (book-wide column) | Unicode runes | Internal, consistent with `total_characters` and the percentage derivations. |
For all-BMP text the two currencies are identical. They diverge on
astral-plane characters (emoji, rare CJK ideographs): one rune, two
UTF-16 units. The server converts at every wire boundary; internal
arithmetic never crosses.
## `context_text` rules
- Starts at the anchor position (it may begin mid-word).
- Whitespace-normalized (all runs of whitespace collapse to single
spaces).
- At most 100 characters.
- Comparison is containment-based (client and server suffixes of the
same block verify in either direction); contexts shorter than 12
chars never match.
## Engine-specific notes
- **readium-based clients** (the Android app): submit
percentage + `context_text` + a CFI generated from the laid-out
WebView. Their locally-generated CFI spine steps use readium
numbering — the server heals the difference; nothing to do.
- **Web (foliate)**: submit all three tiers; foliate CFIs are the same
currency the server stores.
- **KOReader**: submits a CRE xpointer in the `epubcfi` field of the
device payload (historical field name; it is a CRE xpointer, not a
CFI). The server converts CRE → canonical at ingest and canonical →
CRE on pull (`koreader_xpointer` in the metadata response).
- **Kobo**: submits kepub CFI locators, converted server-side the same
way.
- **Fixed-layout content** (PDF/CBZ): the page index is the canonical
locator; CFI/xpointer are meaningless and neither submitted nor
served.
@@ -0,0 +1,91 @@
# Update Media Item Progress
Submit reading progress for a media item. This is the position-authority
ingest point: the server independently verifies the submission against
the book and heals it when the client's projection is wrong.
**Endpoint**: `PUT /api/media-items/:id/progress`
**Auth**: Required (Bearer token)
**Content-Type**: `application/json`
See the [Position Contract](position-contract.md) for the three-tier
submission model, the verification/healing semantics, and the offset
currencies.
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | ---------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Body
All fields are optional; submit what your renderer can observe.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `percentage` | float | Position as a fraction of the whole book (0..1). Tier 1 — the universal field. |
| `context_text` | string | Up to 100 whitespace-normalized chars starting at the anchor. Tier 2 — enables verification and healing. |
| `epubcfi` | string | Standard wrapped CFI (UTF-16 terminals). Tier 3 — the structural anchor. Note: the server re-derives the stored canonical CFI; a client's own spine numbering is healed if it disagrees with the OPF spine. |
| `character_offset` | int | Book-wide rune offset. Accepted but recomputed server-side from the verified anchor on every verified save. |
| `current_page`, `total_pages` | int | Fixed-layout position. For fixed-layout formats the page index is the canonical locator. |
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction. |
| `reading_mode` | string | `paged` or `scrolled` (fixed-layout reader state). |
| `zoom_level`, `scroll_position_x`, `scroll_position_y` | float | Fixed-layout viewport state. |
### Example Request (reflowable, all three tiers)
```http
PUT /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
```
```json
{
"percentage": 0.0415,
"context_text": "was at war with one of these Powers it was generally at peace with the other. But what was strange w",
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
}
```
### Example Request (fixed-layout)
```json
{
"percentage": 0.15,
"current_page": 30,
"total_pages": 194,
"reading_mode": "paged"
}
```
## Behavior
- The submission is verified against the book's XHTML when
`context_text` is present and the book is convertible reflowable: the
CFI is resolved, the text at the anchor is compared with
`context_text`, and mismatches heal by text search (percentage
disambiguates repeats). See [Position Contract](position-contract.md).
- **Anti-clobber guard**: a submission with `percentage < 0.005` is
ignored with `{"status": "ignored"}` when the stored row already holds
a percentage above `0.01` — re-opening a book at its first page does
not wipe real progress.
- The response is the stored row after verification, including the
healed `epubcfi` and the refreshed `character_offset` when
verification ran.
## Response (200 OK)
The saved progress row (same shape as
[GET](get_media_progress.md), minus the GET-time handles). The
`epubcfi` and `percentage` in the response are the server-verified
values, which may differ from the submitted ones when healing occurred.
## Error Responses
| Code | Description |
| ---- | ----------- |
| 400 | Invalid request data |
| 401 | Invalid or expired token |
| 500 | Database error |
@@ -1,68 +0,0 @@
# Update Reading Progress
Update reading progress for a media item. This will sync across all devices via WebSocket.
**Endpoint**: `PUT /api/media-items/{media_id}/progress`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- |
| media_id | string | Yes | Media item UUID |
## Request Body
| Field | Type | Required | Description |
| --------------------------- | ------- | -------- | ------------------------------------------------- |
| source | string | Yes | Progress source (e.g., "web", "koreader", "kobo") |
| location | object | Yes | Location information |
| location.percentage | float | No | Progress percentage (0-1) |
| location.epubcfi | string | No | EPUB CFI location |
| location.character | integer | No | Character offset |
| location.chapter | integer | No | Chapter number |
| location.page | integer | No | Current page |
| location.total_pages | integer | No | Total pages |
| device_metadata | object | No | Device metadata |
| device_metadata.device_type | string | No | Device type |
| device_metadata.user_agent | string | No | User agent string |
### Example Request
```json
{
"source": "web",
"location": {
"percentage": 0.45678,
"epubcfi": "epubcfi(/6/4/2:15)",
"character": 15432,
"chapter": 3,
"page": 89,
"total_pages": 200
},
"device_metadata": {
"device_type": "web",
"user_agent": "Mozilla/5.0..."
}
}
```
## Response (200 OK)
```json
{
"sync_status": "success",
"progress_updated": true,
"devices_notified": ["device-1", "device-2"],
"broadcast": true
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 400 | Invalid location data |
| 401 | Invalid or expired token |
| 404 | Media item not found |
@@ -1,56 +0,0 @@
# Update Universal Progress
Update universal reading progress for a media item.
**Endpoint**: `POST /api/progress/:id`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Media item UUID |
## Request Body
| Field | Type | Required | Description |
| ---------- | ------------- | -------- | ------------------------------------------- |
| percentage | float | No | Progress percentage (0-100) |
| position | integer | No | Current position in bytes |
| page | integer | No | Current page number |
| finished | boolean | No | Whether the book is finished |
| device_id | string (UUID) | No | Device UUID (optional, for tracking source) |
### Example Request
```json
{
"percentage": 75.5,
"position": 1234,
"page": 150,
"finished": false,
"device_id": "device-uuid"
}
```
## Response (200 OK)
```json
{
"media_item_id": "uuid",
"percentage": 75.5,
"position": 1234,
"page": 150,
"finished": false,
"updated_at": "2026-02-08T10:00:00Z"
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------ |
| 400 | Invalid request data |
| 401 | Invalid or expired token |
| 404 | Media item not found |
+8 -3
View File
@@ -30,10 +30,15 @@ The Bookhoard scanner provides comprehensive library management for ebooks, comi
| -------- | ------------------------ |
| EPUB | `.epub` |
| PDF | `.pdf` |
| Kindle | `.mobi`, `.azw`, `.azw3` |
| Kindle | `.mobi` |
| Text | `.txt`, `.rtf` |
| Document | `.doc`, `.docx` |
| Other | `.lit`, `.fb2`, `.pdb` |
| Document | `.docx` |
| Other | `.fb2`, `.pdb` |
DOC (Word 97-2003 binary) and LIT (Microsoft Reader) are deliberately
unsupported: no credible JavaScript parser exists for either, and
compromising on fidelity is against the project's format goals. Files
with these extensions are not scanned.
### Comics
+1 -1
View File
@@ -18,7 +18,7 @@ Initiate a one-time scan of a library for ebooks, manga, or comics.
The scanner automatically detects and processes files based on the library type:
**Ebooks:** .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
**Ebooks:** .epub, .pdf, .mobi, .txt, .rtf, .docx, .fb2, .pdb
**Comics:** .cbz, .cbr, .cb7, .cbt, .pdf
+1 -1
View File
@@ -124,7 +124,7 @@ The following Calibre metadata is **not** imported (by design):
Bookhoard imports Calibre metadata for **all file types**:
- 📚 **Ebooks**: EPUB, MOBI, AZW3, PDF, TXT, etc.
- 📚 **Ebooks**: EPUB, MOBI, PDF, TXT, etc.
- 🎧 **Audiobooks**: MP3, M4B, M4A, etc.
- 📰 **Comics**: CBZ, CBR, CB7, CBT, etc.
- 📄 **Documents**: PDF, DOCX, etc.
+43
View File
@@ -0,0 +1,43 @@
# Android App Setup Guide
The Bookhoard Android app is the native mobile client: browse your libraries, read EPUBs, PDFs, comics and manga, and sync progress, highlights, bookmarks and notes with the server.
## Prerequisites
- ✅ A Bookhoard instance running and reachable from your phone's network
- ✅ The Bookhoard APK installed on your phone (Android 8.0+ / API 26+)
- ✅ Your phone connected to the same network as the server (for a self-hosted LAN setup)
## Installing the app
The app is distributed as a sideloaded APK:
1. Copy the APK to your phone (USB, or any file-sync you trust)
2. Tap the APK to install — approve the "install unknown apps" prompt for the app you installed from (file manager, browser, etc.)
3. Upgrades install straight over the existing app and keep your data (login, downloads, reading state)
## First run
1. **Server URL** — enter your server's address. For a self-hosted LAN setup that is `http://<desktop-LAN-IP>:8765` (plain HTTP is expected here and fully supported; check the server machine's firewall allows port 8765 from your LAN)
2. **Log in** with your Bookhoard account
3. **Device registration** happens automatically — the app registers itself as a synced device so progress and annotations sync under your account
## Android 16+: the local-network permission
On Android 16 and newer, apps need explicit permission to talk to devices on your local network (and to non-HTTPS local addresses in general). **If the permission is missing, the app's logins to a LAN server time out with no visible cause** — the phone silently drops the traffic.
- The app **asks for the permission by itself** during setup, as soon as you enter a local server address — grant it when prompted
- If it was denied (or you missed the prompt): **Settings → Apps → Bookhoard → Permissions → "Access local network devices" → Allow**, then try again
- Servers reached over the public internet (HTTPS) are not affected by this permission
## Troubleshooting login failures
The login screen prints the underlying error after "Could not reach server: …" — read it to narrow the cause:
| Error | Meaning | What to check |
|---|---|---|
| `SocketTimeoutException` | The phone sent nothing that reached the server | On Android 16+ this is most often the **local-network permission** (above). Otherwise: wrong IP, phone on a different network/VLAN, or server down |
| `ConnectException` (connection refused/blocked) | The phone reached the machine but nothing answered | Server container down, or a firewall rejecting port 8765 |
| `UnknownHostException` | The hostname didn't resolve | Typo in the server URL, or a DNS/name issue (raw IPs avoid this) |
To verify the server is reachable from the phone at all, open the same URL in the phone's browser — the browser is not subject to the per-app local-network permission, so if the browser works but the app times out on Android 16+, it is the permission.
+6
View File
@@ -6,6 +6,12 @@ Welcome to the Bookhoard user documentation. This section contains guides for us
Learn how to configure your e-reader devices to sync with Bookhoard:
- **[Android App Guide](devices/android-app.md)** - The native Android client
- Installing and upgrading the APK
- Connecting to a self-hosted server over LAN
- The Android 16+ local-network permission
- Troubleshooting login failures
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
- Installation on Kindle/Kobo/PocketBook
- Plugin setup with server-side device approval
+9
View File
@@ -31,6 +31,7 @@ type Collections struct {
Priority pgtype.Int4 `db:"priority" json:"priority"`
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
}
type DeviceCatalogs struct {
@@ -538,6 +539,14 @@ type UserDashboardPreferences struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type UserDashboardPreferencesGlobal struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
CollectionOrder []string `db:"collection_order" json:"collection_order"`
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
+24
View File
@@ -30,6 +30,7 @@ type Querier interface {
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
@@ -79,6 +80,11 @@ type Querier interface {
// MEDIA ITEM FORMATS QUERIES
// Create media item format
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
// API create path: race-safe create-or-detect. On a duplicate
// (library_id, file_path) this inserts NOTHING and returns no row
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
// and a duplicate flag instead of pretending it created something.
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
// Media Notes queries
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
@@ -174,6 +180,10 @@ type Querier interface {
// CAROUSEL-STYLE DASHBOARD
// ============================================
// Dashboard preferences queries
// The handler routes by scope: a Valid library_id targets the per-library
// table below; an absent one targets user_dashboard_preferences_global
// (the "All Libraries" view). Never pass NULL here — the loose NULL match
// was removed because it returned an arbitrary library's row.
GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error)
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
@@ -200,6 +210,8 @@ type Querier interface {
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
// Global (all-libraries) scope: exactly one row per user.
GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error)
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
@@ -243,6 +255,7 @@ type Querier interface {
// ============================================
GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error)
GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error)
GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error)
// Get media item by OPF identifier
GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error)
// Get media item by OPF UUID
@@ -299,6 +312,12 @@ type Querier interface {
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error)
// System collections are permanent: identity is (user_id, query_type).
// Restore/reset operate IN PLACE (the row id — and everything referencing
// it: shelf mappings, prefs order, links — never churns). Deleted sections
// are TOMBSTONED (deleted_at) and stay invisible until restored.
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error)
// Dashboard collections queries
GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
// SYSTEM CONFIG QUERIES
@@ -419,6 +438,9 @@ type Querier interface {
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
// Full reset, in place: canonical metadata, back on the dashboard,
// un-tombstoned. Item assignments are cleared by the caller ("full").
RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error)
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
RevokeDevice(ctx context.Context, id pgtype.UUID) error
// Revoke OPDS token
@@ -443,6 +465,7 @@ type Querier interface {
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
@@ -503,6 +526,7 @@ type Querier interface {
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
+510 -65
View File
@@ -206,6 +206,15 @@ func (q *Queries) CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 floa
return err
}
const ClearCollectionItems = `-- name: ClearCollectionItems :exec
DELETE FROM collection_items WHERE collection_id = $1
`
func (q *Queries) ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error {
_, err := q.db.Exec(ctx, ClearCollectionItems, collectionID)
return err
}
const ClearDeviceSyncQueue = `-- name: ClearDeviceSyncQueue :exec
DELETE FROM sync_queue WHERE device_id = $1
`
@@ -351,7 +360,7 @@ const CreateCollection = `-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type CreateCollectionParams struct {
@@ -391,6 +400,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
@@ -1164,6 +1174,181 @@ func (q *Queries) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItem
return i, err
}
const CreateMediaItemIfNew = `-- name: CreateMediaItemIfNew :one
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
ON CONFLICT (library_id, file_path) DO NOTHING
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type CreateMediaItemIfNewParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags []string `db:"tags" json:"tags"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors []string `db:"contributors" json:"contributors"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
Language pgtype.Text `db:"language" json:"language"`
Edition pgtype.Text `db:"edition" json:"edition"`
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
Genre pgtype.Text `db:"genre" json:"genre"`
CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"`
GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"`
OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_at"`
MangaType pgtype.Text `db:"manga_type" json:"manga_type"`
ReadingDirection pgtype.Text `db:"reading_direction" json:"reading_direction"`
SeriesCount pgtype.Int4 `db:"series_count" json:"series_count"`
Volume pgtype.Int4 `db:"volume" json:"volume"`
Imprint pgtype.Text `db:"imprint" json:"imprint"`
AgeRating pgtype.Text `db:"age_rating" json:"age_rating"`
WebUrl pgtype.Text `db:"web_url" json:"web_url"`
StoryArc pgtype.Text `db:"story_arc" json:"story_arc"`
IsBlackAndWhite pgtype.Bool `db:"is_black_and_white" json:"is_black_and_white"`
MetadataNotes pgtype.Text `db:"metadata_notes" json:"metadata_notes"`
CommunityRating pgtype.Float8 `db:"community_rating" json:"community_rating"`
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
}
// API create path: race-safe create-or-detect. On a duplicate
// (library_id, file_path) this inserts NOTHING and returns no row
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
// and a duplicate flag instead of pretending it created something.
func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) {
row := q.db.QueryRow(ctx, CreateMediaItemIfNew,
arg.LibraryID,
arg.Title,
arg.Author,
arg.Isbn,
arg.Description,
arg.FilePath,
arg.FileSize,
arg.MimeType,
arg.CoverImagePath,
arg.Series,
arg.SeriesNumber,
arg.Tags,
arg.TagsSearch,
arg.Asin,
arg.DatePublished,
arg.Publisher,
arg.Contributors,
arg.ContributorsSearch,
arg.Language,
arg.Edition,
arg.PageCount,
arg.Genre,
arg.CopyrightYear,
arg.GoodreadsID,
arg.OpenlibraryID,
arg.GoogleBooksID,
arg.AddedByAdminID,
arg.CreatedAt,
arg.ImportedAt,
arg.MangaType,
arg.ReadingDirection,
arg.SeriesCount,
arg.Volume,
arg.Imprint,
arg.AgeRating,
arg.WebUrl,
arg.StoryArc,
arg.IsBlackAndWhite,
arg.MetadataNotes,
arg.CommunityRating,
arg.AlternateInfo,
arg.ScanInformation,
arg.Summary,
arg.LibraryTypeName,
)
var i MediaItems
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.ImportedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
&i.EntitlementID,
&i.RevisionNumber,
&i.KoboContentID,
&i.KoboMetadata,
&i.MangaType,
&i.ReadingDirection,
&i.SeriesCount,
&i.Volume,
&i.Imprint,
&i.AgeRating,
&i.WebUrl,
&i.StoryArc,
&i.IsBlackAndWhite,
&i.MetadataNotes,
&i.CommunityRating,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
&i.ContributorsSearch,
&i.FileSha256,
&i.OpfIdentifier,
&i.OpfUuid,
&i.HashConfidence,
)
return i, err
}
const CreateMediaNote = `-- name: CreateMediaNote :one
INSERT INTO media_notes (media_item_id, user_id, content, position)
VALUES ($1, $2, $3, $4)
@@ -1724,7 +1909,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
const CreateSystemCollection = `-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type CreateSystemCollectionParams struct {
@@ -1764,6 +1949,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
@@ -2593,7 +2779,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
}
const GetCollection = `-- name: GetCollection :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE id = $1
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
`
// Get collection
@@ -2614,6 +2800,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
@@ -2846,7 +3033,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
SELECT
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at,
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
COUNT(ci.id) as book_count
FROM collections c
LEFT JOIN collection_items ci ON c.id = ci.collection_id
@@ -2868,6 +3055,7 @@ type GetCollectionWithBookCountRow struct {
Priority pgtype.Int4 `db:"priority" json:"priority"`
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
BookCount int64 `db:"book_count" json:"book_count"`
}
@@ -2889,13 +3077,14 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
&i.BookCount,
)
return i, err
}
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections WHERE user_id = $1 ORDER BY created_at DESC
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
`
// Get collections by user
@@ -2922,6 +3111,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -2934,10 +3124,11 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
}
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1
AND c.deleted_at IS NULL
`
// Get collections for book
@@ -2964,6 +3155,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -3276,7 +3468,7 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer
const GetDashboardPreferences = `-- name: GetDashboardPreferences :one
SELECT id, user_id, library_id, hidden_collections, collection_order, items_per_section, created_at, updated_at FROM user_dashboard_preferences
WHERE user_id = $1 AND ($2::uuid IS NULL OR library_id = $2::uuid)
WHERE user_id = $1 AND library_id = $2::uuid
`
type GetDashboardPreferencesParams struct {
@@ -3288,6 +3480,10 @@ type GetDashboardPreferencesParams struct {
// CAROUSEL-STYLE DASHBOARD
// ============================================
// Dashboard preferences queries
// The handler routes by scope: a Valid library_id targets the per-library
// table below; an absent one targets user_dashboard_preferences_global
// (the "All Libraries" view). Never pass NULL here — the loose NULL match
// was removed because it returned an arbitrary library's row.
func (q *Queries) GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error) {
row := q.db.QueryRow(ctx, GetDashboardPreferences, arg.UserID, arg.LibraryID)
var i UserDashboardPreferences
@@ -3846,6 +4042,25 @@ func (q *Queries) GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (Get
return i, err
}
const GetGlobalDashboardPreferences = `-- name: GetGlobalDashboardPreferences :one
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
WHERE user_id = $1
`
// Global (all-libraries) scope: exactly one row per user.
func (q *Queries) GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error) {
row := q.db.QueryRow(ctx, GetGlobalDashboardPreferences, userID)
var i UserDashboardPreferencesGlobal
err := row.Scan(
&i.UserID,
&i.HiddenCollections,
&i.CollectionOrder,
&i.ItemsPerSection,
&i.UpdatedAt,
)
return i, err
}
const GetHashConflict = `-- name: GetHashConflict :one
SELECT id, library_id, file_sha256, status, resolution, resolved_by, created_at, resolved_at FROM hash_conflicts WHERE id = $1
`
@@ -5274,6 +5489,87 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
return i, err
}
const GetMediaItemByLibraryAndPath = `-- name: GetMediaItemByLibraryAndPath :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE library_id = $1 AND file_path = $2
`
type GetMediaItemByLibraryAndPathParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FilePath string `db:"file_path" json:"file_path"`
}
func (q *Queries) GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error) {
row := q.db.QueryRow(ctx, GetMediaItemByLibraryAndPath, arg.LibraryID, arg.FilePath)
var i MediaItems
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.ImportedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
&i.EntitlementID,
&i.RevisionNumber,
&i.KoboContentID,
&i.KoboMetadata,
&i.MangaType,
&i.ReadingDirection,
&i.SeriesCount,
&i.Volume,
&i.Imprint,
&i.AgeRating,
&i.WebUrl,
&i.StoryArc,
&i.IsBlackAndWhite,
&i.MetadataNotes,
&i.CommunityRating,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
&i.ContributorsSearch,
&i.FileSha256,
&i.OpfIdentifier,
&i.OpfUuid,
&i.HashConfidence,
)
return i, err
}
const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1
`
@@ -7050,11 +7346,52 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
return i, err
}
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true
`
type GetSystemCollectionByQueryTypeParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
QueryType pgtype.Text `db:"query_type" json:"query_type"`
}
// System collections are permanent: identity is (user_id, query_type).
// Restore/reset operate IN PLACE (the row id — and everything referencing
// it: shelf mappings, prefs order, links — never churns). Deleted sections
// are TOMBSTONED (deleted_at) and stay invisible until restored.
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error) {
row := q.db.QueryRow(ctx, GetSystemCollectionByQueryType, arg.UserID, arg.QueryType)
var i Collections
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at FROM collections
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND is_system_collection = true
AND show_on_dashboard = true
AND deleted_at IS NULL
ORDER BY priority ASC
`
@@ -7082,6 +7419,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -7634,10 +7972,11 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
}
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at FROM collections c
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
AND c.is_system_collection = false
AND c.deleted_at IS NULL
ORDER BY priority ASC
`
@@ -7664,6 +8003,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@@ -10800,6 +11140,59 @@ func (q *Queries) RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNote
return result.RowsAffected(), nil
}
const RestoreSystemCollectionRow = `-- name: RestoreSystemCollectionRow :one
UPDATE collections
SET name = $2,
description = $3,
icon = $4,
color = $5,
priority = $6,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type RestoreSystemCollectionRowParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description pgtype.Text `db:"description" json:"description"`
Icon pgtype.Text `db:"icon" json:"icon"`
Color pgtype.Text `db:"color" json:"color"`
Priority pgtype.Int4 `db:"priority" json:"priority"`
}
// Full reset, in place: canonical metadata, back on the dashboard,
// un-tombstoned. Item assignments are cleared by the caller ("full").
func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error) {
row := q.db.QueryRow(ctx, RestoreSystemCollectionRow,
arg.ID,
arg.Name,
arg.Description,
arg.Icon,
arg.Color,
arg.Priority,
)
var i Collections
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL
`
@@ -11285,46 +11678,48 @@ WHERE COALESCE(lv.is_visible, true) = true
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy language filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.language, '')) > 0.3)
AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
AND ($7 = '' OR EXISTS (
AND ($8 = '' OR EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($7, tag) > 0.3
WHERE word_similarity($8, tag) > 0.3
))
-- Year range (exact) - prioritize date_published, fallback to copyright_year
AND (
$8 = 0 OR
EXTRACT(YEAR FROM mi.date_published) >= $8 OR
(mi.date_published IS NULL AND mi.copyright_year >= $8)
$9 = 0 OR
EXTRACT(YEAR FROM mi.date_published) >= $9 OR
(mi.date_published IS NULL AND mi.copyright_year >= $9)
)
AND (
$9 = 0 OR
EXTRACT(YEAR FROM mi.date_published) <= $9 OR
(mi.date_published IS NULL AND mi.copyright_year <= $9)
$10 = 0 OR
EXTRACT(YEAR FROM mi.date_published) <= $10 OR
(mi.date_published IS NULL AND mi.copyright_year <= $10)
)
-- Boolean (exact)
AND (
$10::bool IS NULL OR -- Not specified = show all
($10::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
($10::bool IS FALSE AND mi.cover_image_path IS NULL)
$11::bool IS NULL OR -- Not specified = show all
($11::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
($11::bool IS FALSE AND mi.cover_image_path IS NULL)
)
-- Search query (fuzzy or exact based on quotes)
AND (
$11 = '' OR
$12 = '' OR
-- Fuzzy search (default)
$12 = false AND (
word_similarity($11, mi.title) > 0.3 OR
word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR
$13 = false AND (
word_similarity($12, mi.title) > 0.3 OR
word_similarity($12, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($12, COALESCE(mi.series, '')) > 0.3 OR
EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($11, tag) > 0.3
WHERE word_similarity($12, tag) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
WHERE word_similarity($11, contributor) > 0.3
WHERE word_similarity($12, contributor) > 0.3
LIMIT 1
)
) OR
@@ -11338,86 +11733,88 @@ WHERE COALESCE(lv.is_visible, true) = true
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
-- )
-- Exact search (with quotes) - true exact match, not substring
$12 = true AND (
mi.title = $11 OR
COALESCE(mi.author, '') = $11 OR
COALESCE(mi.series, '') = $11 OR
$11 = ANY(mi.tags_search) OR
$11 = ANY(mi.contributors_search)
$13 = true AND (
mi.title = $12 OR
COALESCE(mi.author, '') = $12 OR
COALESCE(mi.series, '') = $12 OR
$12 = ANY(mi.tags_search) OR
$12 = ANY(mi.contributors_search)
)
)
ORDER BY
-- Primary sort: relevance score when searching
CASE
WHEN $11 != '' THEN
WHEN $12 != '' THEN
GREATEST(
CASE WHEN $12 = false THEN
word_similarity($11, mi.title)
CASE WHEN $13 = false THEN
word_similarity($12, mi.title)
ELSE 0 END,
CASE WHEN $12 = false THEN
word_similarity($11, COALESCE(mi.author, ''))
CASE WHEN $13 = false THEN
word_similarity($12, COALESCE(mi.author, ''))
ELSE 0 END,
word_similarity($3, COALESCE(mi.author, '')),
word_similarity($5, COALESCE(mi.genre, '')),
(SELECT MAX(word_similarity($7, tag))
word_similarity($6, COALESCE(mi.publisher, '')),
(SELECT MAX(word_similarity($8, tag))
FROM unnest(mi.tags_search) AS tag)
)
ELSE 0
END DESC,
-- Secondary sort: user-specified sort parameter
CASE
WHEN $13 = 'title ASC' THEN mi.title
WHEN $14 = 'title ASC' THEN mi.title
ELSE ''
END ASC,
CASE
WHEN $13 = 'title DESC' THEN mi.title
WHEN $14 = 'title DESC' THEN mi.title
ELSE ''
END DESC,
CASE
WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '')
WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '')
ELSE ''
END ASC,
CASE
WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '')
WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '')
ELSE ''
END DESC,
CASE
WHEN $13 = 'created_at ASC' THEN mi.created_at
WHEN $14 = 'created_at ASC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END ASC,
CASE
WHEN $13 = 'created_at DESC' THEN mi.created_at
WHEN $14 = 'created_at DESC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END DESC,
CASE
WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
WHEN $14 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END ASC,
CASE
WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
WHEN $14 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END DESC,
-- Tertiary sort: title (default fallback)
mi.title ASC
LIMIT $15 OFFSET $14
LIMIT $16 OFFSET $15
`
type SearchMediaItemsUnifiedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
YearMin interface{} `db:"year_min" json:"year_min"`
YearMax interface{} `db:"year_max" json:"year_max"`
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
SearchQuery interface{} `db:"search_query" json:"search_query"`
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
Sort interface{} `db:"sort" json:"sort"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
YearMin interface{} `db:"year_min" json:"year_min"`
YearMax interface{} `db:"year_max" json:"year_max"`
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
SearchQuery interface{} `db:"search_query" json:"search_query"`
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
Sort interface{} `db:"sort" json:"sort"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
}
type SearchMediaItemsUnifiedRow struct {
@@ -11476,6 +11873,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
arg.AuthorFilter,
arg.SeriesFilter,
arg.GenreFilter,
arg.PublisherFilter,
arg.LanguageFilter,
arg.TagsFilter,
arg.YearMin,
@@ -11841,6 +12239,15 @@ func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) er
return err
}
const TombstoneSystemCollection = `-- name: TombstoneSystemCollection :exec
UPDATE collections SET deleted_at = NOW() WHERE id = $1
`
func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, TombstoneSystemCollection, id)
return err
}
const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections
SET
@@ -11851,7 +12258,7 @@ SET
auto_assign_rules = $6,
view_settings = $7
WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type UpdateCollectionParams struct {
@@ -11890,6 +12297,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
@@ -13834,6 +14242,43 @@ func (q *Queries) UpsertDashboardPreferences(ctx context.Context, arg UpsertDash
return i, err
}
const UpsertGlobalDashboardPreferences = `-- name: UpsertGlobalDashboardPreferences :one
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id)
DO UPDATE SET
hidden_collections = EXCLUDED.hidden_collections,
collection_order = EXCLUDED.collection_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at
`
type UpsertGlobalDashboardPreferencesParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
CollectionOrder []string `db:"collection_order" json:"collection_order"`
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
}
func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error) {
row := q.db.QueryRow(ctx, UpsertGlobalDashboardPreferences,
arg.UserID,
arg.HiddenCollections,
arg.CollectionOrder,
arg.ItemsPerSection,
)
var i UserDashboardPreferencesGlobal
err := row.Scan(
&i.UserID,
&i.HiddenCollections,
&i.CollectionOrder,
&i.ItemsPerSection,
&i.UpdatedAt,
)
return i, err
}
const UpsertPanelData = `-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4)
+74 -3
View File
@@ -154,6 +154,19 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING *;
-- name: CreateMediaItemIfNew :one
-- API create path: race-safe create-or-detect. On a duplicate
-- (library_id, file_path) this inserts NOTHING and returns no row
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
-- and a duplicate flag instead of pretending it created something.
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
ON CONFLICT (library_id, file_path) DO NOTHING
RETURNING *;
-- name: GetMediaItemByLibraryAndPath :one
SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
-- name: GetMediaItem :one
SELECT * FROM media_items WHERE id = $1;
@@ -628,6 +641,8 @@ WHERE COALESCE(lv.is_visible, true) = true
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy language filter
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
@@ -702,6 +717,7 @@ ORDER BY
ELSE 0 END,
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')),
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
FROM unnest(mi.tags_search) AS tag)
)
@@ -2148,7 +2164,7 @@ SELECT * FROM collections WHERE id = $1;
-- Get collections by user
-- name: GetCollectionsByUser :many
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC;
SELECT * FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC;
-- Get collection with book count
-- name: GetCollectionWithBookCount :one
@@ -2205,7 +2221,8 @@ ORDER BY ci.added_at DESC;
SELECT c.*
FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1;
WHERE ci.media_item_id = $1
AND c.deleted_at IS NULL;
-- Check if book is in collection
-- name: IsBookInCollection :one
@@ -2432,9 +2449,13 @@ LIMIT $1 OFFSET $2;
-- ============================================
-- Dashboard preferences queries
-- The handler routes by scope: a Valid library_id targets the per-library
-- table below; an absent one targets user_dashboard_preferences_global
-- (the "All Libraries" view). Never pass NULL here — the loose NULL match
-- was removed because it returned an arbitrary library's row.
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
WHERE user_id = sqlc.narg('user_id') AND library_id = sqlc.narg('library_id')::uuid;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
@@ -2456,12 +2477,29 @@ SET hidden_collections = $2,
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- Global (all-libraries) scope: exactly one row per user.
-- name: GetGlobalDashboardPreferences :one
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
WHERE user_id = $1;
-- name: UpsertGlobalDashboardPreferences :one
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id)
DO UPDATE SET
hidden_collections = EXCLUDED.hidden_collections,
collection_order = EXCLUDED.collection_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at;
-- Dashboard collections queries
-- name: GetSystemCollectionsForDashboard :many
SELECT * FROM collections
WHERE user_id = $1
AND is_system_collection = true
AND show_on_dashboard = true
AND deleted_at IS NULL
ORDER BY priority ASC;
-- name: GetUserCollectionsForDashboard :many
@@ -2469,6 +2507,7 @@ SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
AND c.is_system_collection = false
AND c.deleted_at IS NULL
ORDER BY priority ASC;
-- name: DeleteUserSystemCollection :exec
@@ -2477,6 +2516,38 @@ WHERE user_id = $1
AND name = $2
AND is_system_collection = true;
-- System collections are permanent: identity is (user_id, query_type).
-- Restore/reset operate IN PLACE (the row id — and everything referencing
-- it: shelf mappings, prefs order, links — never churns). Deleted sections
-- are TOMBSTONED (deleted_at) and stay invisible until restored.
-- name: GetSystemCollectionByQueryType :one
-- Unfiltered by design: restore/seed must resolve tombstoned rows too.
SELECT * FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true;
-- name: TombstoneSystemCollection :exec
UPDATE collections SET deleted_at = NOW() WHERE id = $1;
-- name: RestoreSystemCollectionRow :one
-- Full reset, in place: canonical metadata, back on the dashboard,
-- un-tombstoned. Item assignments are cleared by the caller ("full").
UPDATE collections
SET name = $2,
description = $3,
icon = $4,
color = $5,
priority = $6,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING *;
-- name: ClearCollectionItems :exec
DELETE FROM collection_items WHERE collection_id = $1;
-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
+7
View File
@@ -65,6 +65,7 @@ var SettingDefaults = []SettingDefault{
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
{Key: "registration_enabled", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Public Registration", Description: "Allow users to create their own accounts (admins can always create accounts)"},
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
@@ -188,6 +189,12 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
// AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
// RegistrationEnabled reports whether public signup is allowed. Admin user
// creation is never affected — only self-service registration rides this
// flag; handlers additionally grant the first-user exception (see
// handlers/auth.go Register).
func (r *SettingsRegistry) RegistrationEnabled() bool { return r.getBool("registration_enabled") }
// DefaultTimezone returns the configured default timezone name.
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
+46 -2
View File
@@ -101,6 +101,7 @@ type UserProfile struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
Theme string `json:"theme,omitempty"`
}
type UpdateProfileRequest struct {
@@ -173,6 +174,21 @@ func (h *AuthHandler) Register(c *echo.Context) error {
req.Role = strings.ToLower(req.Role)
}
// Public registration can be disabled by admins. The giveaway exception
// is an unconfigured server: with zero admins the first-user-becomes-
// admin bootstrap path must stay reachable (mirrors setupRedirect-
// Middleware's "users exist" reasoning). Admin account creation via the
// admin API never rides this route, so it is unaffected regardless.
if h.settings != nil && !h.settings.RegistrationEnabled() {
count, countErr := h.db.CountAdmins(c.Request().Context())
if countErr != nil || count > 0 {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Registration is disabled on this server</div>`)
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "registration is disabled on this server"})
}
}
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusConflict, `<div class="text-red-500">Email already exists</div>`)
@@ -485,7 +501,14 @@ window.location.href = '%s';
// GetProfile handles GET /api/auth/profile
func (h *AuthHandler) GetProfile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
ctxUser := MustGetAuthenticatedUser(c)
// The context user is a JWT-claims stub (id/email/username/role) —
// it carries no theme. Read the full row: clients (the Android app)
// sync their app-chrome theme from this field.
user, err := h.db.GetUser(c.Request().Context(), ctxUser.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load profile"})
}
firstName := ""
if user.FirstName.Valid {
@@ -502,6 +525,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
})
}
@@ -1067,7 +1091,27 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
}
for _, col := range defaultCollections {
_, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
// Idempotent by the STABLE identity (user + query_type): a
// preserved or renamed row heals to canonical metadata instead of
// duplicating (the naming-drift bug). Insert only when absent.
existing, err := h.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: userID,
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
})
if err == nil {
if _, uerr := h.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: existing.ID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
Icon: pgtype.Text{String: col.Icon, Valid: true},
Color: pgtype.Text{String: col.Color, Valid: true},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
}); uerr != nil {
return uerr
}
continue
}
_, err = h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: userID,
Name: col.Name,
Description: pgtype.Text{String: col.Description, Valid: true},
+3
View File
@@ -111,6 +111,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
req.ViewSettings,
)
if err != nil {
if errors.Is(err, services.ErrCollectionNameTaken) {
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
+14 -5
View File
@@ -75,7 +75,9 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
// nil (JSON null/absent) = the global All Libraries scope;
// empty string is a LOST value, not a scope — rejected loudly.
LibraryID *string `json:"library_id"`
HiddenCollections []string `json:"hidden_collections"`
CollectionOrder []string `json:"collection_order"`
ItemsPerSection int `json:"items_per_section"`
@@ -85,9 +87,16 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
var libUUID pgtype.UUID
if req.LibraryID != nil {
if *req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
parsed, err := uuid.Parse(*req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
// Sanitize preferences (remove duplicates)
@@ -97,7 +106,7 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
LibraryID: libUUID,
HiddenCollections: req.HiddenCollections,
CollectionOrder: req.CollectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
+32 -29
View File
@@ -254,9 +254,9 @@ type KoboInitResponse struct {
}
type KoboSyncStatus struct {
Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"`
Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"`
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
}
@@ -448,16 +448,17 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
CurrentPage: currentPage,
TotalPages: totalPages,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
CurrentPage: currentPage,
TotalPages: totalPages,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
ClearLocator: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
@@ -798,14 +799,15 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
ClearLocator: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
@@ -860,14 +862,15 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "bookhoard",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: false,
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "bookhoard",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: false,
ClearLocator: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
+41 -19
View File
@@ -93,7 +93,22 @@ func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos
if pos0 == "" || !ec.convertible() {
return "", ""
}
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
// Primary: anchor both endpoints by the selection text itself. One
// document match yields a same-spine ordered pair — the invariant the
// web reader's range-CFI builder needs. Independent per-endpoint
// conversion mis-anchors any selection past a block's first text node
// (crengine text-node indices don't survive to the raw XHTML).
startLoc := wsync.ConvertHighlightRangeToCanonical(ec.epubPath, ec.mediaItem.FormatGroup, pos0, pos1, contextText, percentage)
if startLoc.Precision == "exact" || startLoc.Precision == "structural" {
startCFI := webUsableCFI(startLoc)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsableCFI(wsync.CanonicalLocator{CFI: startLoc.EndCFI, Precision: startLoc.Precision})
}
return startCFI, endCFI
}
// Fallback: legacy independent per-endpoint conversion.
startLoc = wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
startCFI := webUsableCFI(startLoc)
endCFI := ""
// A text-search start matched the selection text itself: its extent
@@ -219,24 +234,24 @@ type KOReaderProgressRequest struct {
}
type KOReaderBookProgress struct {
UUID string `json:"uuid,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title,omitempty"`
Authors []string `json:"authors,omitempty"`
Progress float64 `json:"progress"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read,omitempty"`
FilePath string `json:"file_path,omitempty"`
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
ContextText *string `json:"context_text,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
UUID string `json:"uuid,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Title string `json:"title,omitempty"`
Authors []string `json:"authors,omitempty"`
Progress float64 `json:"progress"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read,omitempty"`
FilePath string `json:"file_path,omitempty"`
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
ContextText *string `json:"context_text,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
// Device-side deletions, reported by dedup key. Keys refer to annotations
// the device previously received from the server (or echoes of its own
// pushes); the device only flags a deletion after observing the key in a
@@ -862,6 +877,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
if h.progressSvc != nil {
epubcfi := book.Epubcfi
// No position pointer from the device, or a conversion that
// declined below: the write is percentage-only by intent. The
// service then clears any inherited stale anchor instead of
// re-pairing it with the new percentage.
clearLocator := epubcfi == nil
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
// Same facade every annotation uses: cached converter,
// structural-first resolution, guarded fallbacks. The facade
@@ -897,6 +917,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
// and the device keeps its own native position.
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
epubcfi = nil
clearLocator = true
}
}
}
@@ -916,6 +937,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
DeviceType: "koreader",
DeviceName: deviceModel,
Broadcast: true,
ClearLocator: clearLocator,
}
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
+72 -22
View File
@@ -102,13 +102,13 @@ type UpdateMediaItemRequest struct {
// CreateMediaNoteRequest represents the request for creating a media note
type CreateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Content string `json:"content" validate:"required,min=1,max=100000"`
Position string `json:"position"`
}
// UpdateMediaNoteRequest represents the request for updating a media note
type UpdateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=10000"`
Content string `json:"content" validate:"required,min=1,max=100000"`
Position string `json:"position"`
}
@@ -120,7 +120,7 @@ type CreateMediaHighlightRequest struct {
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteText string `json:"note_text" validate:"max=100000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
@@ -135,7 +135,7 @@ type UpdateMediaHighlightRequest struct {
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteText string `json:"note_text" validate:"max=100000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
@@ -146,7 +146,7 @@ type UpdateMediaHighlightRequest struct {
type CreateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Position string `json:"position" validate:"max=100"`
Notes string `json:"notes" validate:"max=10000"`
Notes string `json:"notes" validate:"max=100000"`
CfiPosition string `json:"cfi_position" validate:"max=255"`
PageNumber int32 `json:"page_number"`
ChapterNumber int32 `json:"chapter_number"`
@@ -160,7 +160,7 @@ type CreateMediaBookmarkRequest struct {
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
type UpdateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Notes string `json:"notes" validate:"max=10000"`
Notes string `json:"notes" validate:"max=100000"`
Position string `json:"position" validate:"max=100"`
}
@@ -953,6 +953,37 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
"last_sync_timestamp": progress.LastSyncTimestamp,
}
// Position authority: re-verify the stored anchor and derive the
// client-facing handles (cssSelector + anchor document href) server-
// side, so clients scroll to an address instead of parsing CFIs.
if progress.Epubcfi.Valid && wsync.IsConvertibleFormat(progress.FormatGroup) && mh.libraryService != nil {
contextText := ""
if progress.ContextText.Valid {
contextText = progress.ContextText.String
}
pct := 0.0
if progress.Percentage.Valid {
pct = progress.Percentage.Float64
}
if mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err == nil {
if path, perr := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
if finalCFI, sel, href, charOff, _, _, _, verr := wsync.VerifyProgressAnchor(path, progress.Epubcfi.String, contextText, pct); verr == nil && sel != "" {
resp["css_selector"] = sel
resp["anchor_href"] = href
if charOff != nil {
resp["char_offset"] = *charOff
}
if finalCFI != "" {
resp["epubcfi"] = finalCFI
}
}
}
}
}
if progress.ContextText.Valid {
resp["context_text"] = progress.ContextText.String
}
return c.JSON(http.StatusOK, resp)
}
@@ -1163,7 +1194,10 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
})
}
mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
// Race-safe create-or-detect: on a duplicate (library_id, file_path)
// the insert writes nothing and returns no row — answer with the
// EXISTING item, a duplicate flag, and 200 OK (not a 201 lie).
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
@@ -1184,11 +1218,25 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
ContributorsSearch: contributorsSearch,
AddedByAdminID: user.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
existing, gerr := mh.db.GetMediaItemByLibraryAndPath(c.Request().Context(), database.GetMediaItemByLibraryAndPathParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
FilePath: req.FilePath,
})
if gerr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": gerr.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"duplicate": true,
"message": "A media item already exists for this file_path in this library",
"item": existing,
})
}
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: created.ID.Bytes, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -2102,6 +2150,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
publisherFilter := c.QueryParam("publisher_filter")
tagsFilter := c.QueryParam("tags_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
@@ -2121,20 +2170,21 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
// Build search params
params := services.SearchParams{
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
PublisherFilter: publisherFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
}
c.Logger().Info("SearchMediaItems called",
-115
View File
@@ -97,121 +97,6 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
}
// UpdateUniversalProgress updates progress with automatic conversion
func (h *Handler) UpdateUniversalProgress(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
mediaItemID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
}
var req struct {
Source string `json:"source"`
Location struct {
Percentage *float64 `json:"percentage"`
Page *int `json:"page"`
TotalPages *int `json:"total_pages"`
Epubcfi *string `json:"epubcfi"`
Character *int64 `json:"character"`
Chapter *int `json:"chapter"`
ViewportY *float64 `json:"viewport_y"`
} `json:"location"`
DeviceMetadata struct {
DeviceType string `json:"device_type"`
UserAgent string `json:"user_agent"`
} `json:"device_metadata"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
mediaItem, err := h.db.GetMediaItem(context.Background(), pgtype.UUID{Bytes: mediaItemID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "media item not found")
}
percentage := 0.0
if req.Location.Percentage != nil {
percentage = *req.Location.Percentage
} else if req.Location.Page != nil && req.Location.TotalPages != nil {
percentage = wsync.PageToPercentage(*req.Location.Page, *req.Location.TotalPages)
}
currentPage := 0
totalPages := 0
if req.Location.Page != nil {
currentPage = *req.Location.Page
}
if req.Location.TotalPages != nil {
totalPages = *req.Location.TotalPages
}
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
totalPages = int(mediaItem.PageCount.Int32)
}
var epubcfi pgtype.Text
if req.Location.Epubcfi != nil {
epubcfi = pgtype.Text{String: *req.Location.Epubcfi, Valid: true}
}
var chapter pgtype.Int4
if req.Location.Chapter != nil {
chapter = pgtype.Int4{Int32: int32(*req.Location.Chapter), Valid: true}
}
var viewportY pgtype.Float8
if req.Location.ViewportY != nil {
viewportY = pgtype.Float8{Float64: *req.Location.ViewportY, Valid: true}
}
var lastSyncDevice, lastSyncSource pgtype.Text
if req.DeviceMetadata.DeviceType != "" {
lastSyncDevice = pgtype.Text{String: req.DeviceMetadata.DeviceType, Valid: true}
lastSyncSource = pgtype.Text{String: req.Source, Valid: true}
}
updated, err := h.db.UpdateUniversalProgress(context.Background(), database.UpdateUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
ViewportY: viewportY,
LastSyncDevice: lastSyncDevice,
LastSyncSource: lastSyncSource,
CurrentPage: pgtype.Int4{Int32: int32(currentPage), Valid: true},
TotalPages: pgtype.Int4{Int32: int32(totalPages), Valid: true},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update progress")
}
// Broadcast progress update to all connected WebSocket clients
deviceName := "Web"
deviceType := "web"
if req.DeviceMetadata.DeviceType != "" {
deviceType = req.DeviceMetadata.DeviceType
deviceName = req.DeviceMetadata.DeviceType
}
h.connManager.BroadcastProgressUpdate(mediaItemID, percentage, wsync.SourceDevice{
ID: uuid.UUID(user.ID.Bytes).String(),
Name: deviceName,
Type: deviceType,
})
return c.JSON(http.StatusOK, map[string]interface{}{
"sync_status": "success",
"progress_updated": true,
"percentage": percentage,
"current_page": currentPage,
"total_pages": totalPages,
"last_read_at": updated.LastReadAt.Time,
})
}
// GetProgressHistory retrieves reading session history
func (h *Handler) GetProgressHistory(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
-51
View File
@@ -590,57 +590,6 @@ func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, h
return []byte{}, nil
}
func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return c.JSON(400, map[string]string{"error": "Invalid media item ID"})
}
// Fetch media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(404, map[string]string{"error": "Media item not found"})
}
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
}
// Check if format requires server-side parsing
requiresServerParsing := false
formatName := ""
switch mediaItem.MimeType.String {
case "application/x-mobipocket-ebook":
requiresServerParsing = true
formatName = "MOBI"
case "application/vnd.amazon.mobi8-ebook":
requiresServerParsing = true
formatName = "AZW3/KF8"
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
requiresServerParsing = true
formatName = "DOCX"
case "application/rtf":
requiresServerParsing = true
formatName = "RTF"
}
if requiresServerParsing {
// Phase 1: Return 501 for server-side formats
return c.JSON(501, map[string]interface{}{
"error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName),
"message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName),
"suggestion": "Please convert your ebook to EPUB format for now",
"format": formatName,
"phase": "2.5",
})
}
// Client-side format - should not call this endpoint
return c.JSON(400, map[string]string{
"error": "This format should be parsed client-side, not on the server",
})
}
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
// comics or manga library should get comic reader treatment: bookmarks
// only — no text-selection highlights, no annotations, no in-book search.
+16 -4
View File
@@ -17,6 +17,7 @@ import (
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/internal/version"
"bookhoard/templates"
"github.com/golang-jwt/jwt/v5"
@@ -61,7 +62,7 @@ func registerFrontendRoutes(cfg *Config) {
var buf bytes.Buffer
sessionExpired := c.QueryParam("session") == "expired"
deleted := c.QueryParam("deleted") == "true"
err := templates.Login(sessionExpired, deleted).Render(c.Request().Context(), &buf)
err := templates.Login(sessionExpired, deleted, templates.SignupVisible()).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -69,6 +70,13 @@ func registerFrontendRoutes(cfg *Config) {
})
e.GET("/register", func(c *echo.Context) error {
// Registration can be disabled by admins. The first-user exception
// keeps the page reachable on an unconfigured server (zero admins) —
// see templates.SignupVisible for the shared reasoning. Redirect
// (not 404) so stale links and bookmarks land somewhere sensible.
if !templates.SignupVisible() {
return c.Redirect(http.StatusFound, "/login")
}
var buf bytes.Buffer
err := templates.Register().Render(c.Request().Context(), &buf)
if err != nil {
@@ -100,7 +108,7 @@ func registerFrontendRoutes(cfg *Config) {
loggedIn = err == nil && token.Valid
}
err = templates.Index(loggedIn).Render(c.Request().Context(), &buf)
err = templates.Index(loggedIn, templates.SignupVisible()).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -112,7 +120,7 @@ func registerFrontendRoutes(cfg *Config) {
// ============================================================================
// Protected frontend routes (no /api prefix)
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
frontendProtected := e.Group("", jwtMiddleware)
// Series browse page
frontendProtected.GET("/series", func(c *echo.Context) error {
@@ -1428,7 +1436,10 @@ func registerFrontendRoutes(cfg *Config) {
// Render template
var buf bytes.Buffer
err = templates.BookDetail(user, detail, "").Render(c.Request().Context(), &buf)
// The deleted-annotations modal states the retention in plain days
// (the tunable's user-facing value), never as a settings name.
tombstoneDays := cfg.Settings.TombstoneTTL() / (24 * time.Hour)
err = templates.BookDetail(user, detail, "", int(tombstoneDays)).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -1455,6 +1466,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "healthy",
"version": version.Version,
"database": "connected",
"scan": map[string]interface{}{
"scan_in_progress": cfg.Worker.HasActiveScans(),
+48 -38
View File
@@ -69,7 +69,7 @@ type Config struct {
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
JobsHandler *handlers.JobsHandler
SidecarHandler *handlers.SidecarHandler
SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService
}
@@ -113,6 +113,32 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
}
// Ghost-session guard: a signature-valid token for a user that
// no longer exists must not authenticate (deleted users kept
// full access until token expiry before this check). A
// primary-key lookup per request — instant revocation, no TTL
// semantics; the guard also covers the SSR groups that used to
// chain ensureUserExistsMiddleware.
if !userExists(cfg, userUUID) {
log.Printf("Ghost session rejected: user %s no longer exists", userIDStr)
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
if wantsHTML(c.Request().Header) {
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
// Non-nil error is REQUIRED: returning c.JSON's nil would
// let the chain continue into handlers whose c.Get("user")
// assertion panics on the never-set user.
return echo.NewHTTPError(http.StatusUnauthorized,
"user_not_found: this account no longer exists; please log in again")
}
c.Set("user", database.Users{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: claims["user_email"].(string),
@@ -138,43 +164,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
})
}
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
userIDStr, ok := c.Get("user_id").(string)
if !ok {
return next(c)
}
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Invalid UUID in user existence check: %v", err)
return next(c)
}
// Check if user exists in database
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("User not found in database: %s", userIDStr)
// Clear invalid cookie
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Redirect to login with session=invalid
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
return next(c)
}
}
// userExists checks the users table for the token's claimed user. Runs on
// every authenticated request: a primary-key lookup, cheap at this scale,
// and it makes deletion revocable the moment it happens.
func userExists(cfg *Config, id uuid.UUID) bool {
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
return err == nil
}
// wantsHTML determines if the request expects HTML response
@@ -255,6 +250,21 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg)
registerDocumentationRoutes(cfg)
// Static bundles are served unhashed (reader.js, index.js), so the
// browser must revalidate on every load — with no Cache-Control it
// fell back to heuristic caching and served stale bundles for hours
// after a deploy (user-visible: fixed web bugs "not fixed"). no-cache
// still allows cheap If-Modified-Since 304 revalidation.
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if strings.HasPrefix(c.Request().URL.Path, "/static/") {
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
}
return next(c)
}
})
e.Static("/static", "web/static")
// Catch-all 404 handler - must be last
+30 -3
View File
@@ -4,13 +4,19 @@ import (
"bookhoard/internal/database"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
)
// ErrCollectionNameTaken: the user already has a collection with this name.
var ErrCollectionNameTaken = errors.New("collection name already exists")
// Rule represents an auto-assign rule for collections
type Rule struct {
ID string `json:"id"`
@@ -72,7 +78,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to create collection: %v", err)
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
// server failure: surface it as 409 so callers can recover.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return database.Collections{}, ErrCollectionNameTaken
}
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
}
return collection, nil
@@ -160,9 +172,24 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
// DeleteCollection deletes a collection
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
// System collections (the 5 dashboard sections) are PERMANENT: a
// delete tombstones the row (invisible everywhere, restorable with
// the same id) instead of destroying it and every reference to it.
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %v", err)
if errors.Is(err, pgx.ErrNoRows) {
return nil // already gone: keep delete idempotent
}
return fmt.Errorf("failed to fetch collection: %w", err)
}
if row.IsSystemCollection.Bool {
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
}
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
}
return nil
+76 -24
View File
@@ -5,9 +5,11 @@ import (
"bookhoard/internal/utils"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -376,6 +378,22 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
}
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
// Absent library = the global (All Libraries) scope: a separate
// one-row-per-user table, so an accidental per-library write can never
// masquerade as global.
if !libraryID.Valid {
g, err := s.db.GetGlobalDashboardPreferences(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return database.UserDashboardPreferences{}, err
}
return database.UserDashboardPreferences{
UserID: g.UserID,
HiddenCollections: g.HiddenCollections,
CollectionOrder: g.CollectionOrder,
ItemsPerSection: g.ItemsPerSection,
UpdatedAt: g.UpdatedAt,
}, nil
}
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: libraryID,
@@ -383,6 +401,24 @@ func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID u
}
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
if !params.LibraryID.Valid {
g, err := s.db.UpsertGlobalDashboardPreferences(ctx, database.UpsertGlobalDashboardPreferencesParams{
UserID: params.UserID,
HiddenCollections: params.HiddenCollections,
CollectionOrder: params.CollectionOrder,
ItemsPerSection: params.ItemsPerSection,
})
if err != nil {
return database.UserDashboardPreferences{}, err
}
return database.UserDashboardPreferences{
UserID: g.UserID,
HiddenCollections: g.HiddenCollections,
CollectionOrder: g.CollectionOrder,
ItemsPerSection: g.ItemsPerSection,
UpdatedAt: g.UpdatedAt,
}, nil
}
return s.db.UpsertDashboardPreferences(ctx, params)
}
@@ -430,34 +466,50 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
return fmt.Errorf("unknown collection: %s", collectionName)
}
if resetType == "keep_books" {
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
}
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
// System collections are PERMANENT rows identified by the stable
// (user_id, query_type) pair — restore never deletes or re-creates,
// so the row id (and every reference to it: device shelf mappings,
// dashboard order/hidden prefs, /collections/{id} links) never churns.
// Resolution includes tombstoned rows: restoring a deleted section
// un-deletes the exact same row.
row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
})
if err != nil {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
if errors.Is(err, pgx.ErrNoRows) {
// Provision: the only path that mints a system-collection row.
_, cerr := s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
return cerr
}
// "full": clear item assignments (a fresh section has none) and reset
// everything, IN PLACE. "keep_books": metadata-only, in place. Both
// un-tombstone, so a deleted section comes back as the same row.
if resetType != "keep_books" {
if err := s.db.ClearCollectionItems(ctx, row.ID); err != nil {
return err
}
}
_, err = s.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: row.ID,
Name: collectionName,
Description: pgtype.Text{String: meta.Description, Valid: true},
Icon: pgtype.Text{String: meta.Icon, Valid: true},
Color: pgtype.Text{String: meta.Color, Valid: true},
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
})
return err
}
+8 -3
View File
@@ -30,7 +30,7 @@ const (
)
var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
}
@@ -59,9 +59,7 @@ var MimeTypes = map[string]string{
".azw3": "application/vnd.amazon.ebook",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-msreader",
".fb2": "application/x-fictionbook+xml",
".pdb": "application/vnd.palm",
}
@@ -301,6 +299,13 @@ func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
}
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Typed-nil interface guard: a nil *LibraryService held in an
// interface passes `!= nil` checks at call sites; degrade instead
// of panicking (kobo markup 500s were exactly this path).
if s == nil || s.db == nil {
return "", fmt.Errorf("library service not configured")
}
// Get library folders for this library
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
if err != nil || len(folders) == 0 {
+56 -26
View File
@@ -662,8 +662,8 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
var bookExtensions = map[string]bool{
".epub": true, ".pdf": true, ".mobi": true, ".azw": true, ".azw3": true,
".fb2": true, ".txt": true, ".rtf": true, ".doc": true, ".docx": true,
".lit": true, ".pdb": true, ".djvu": true,
".fb2": true, ".txt": true, ".rtf": true, ".docx": true,
".pdb": true, ".djvu": true,
".cbz": true, ".cbr": true, ".cb7": true, ".cbt": true,
}
@@ -974,30 +974,12 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
// Set format group, total characters, and chapter count
mimeType := s.getMimeType(path)
ext := strings.ToLower(filepath.Ext(path))
var formatGroup string
var isReflowable, hasFixedLayout bool
switch ext {
case ".epub":
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
if fixedErr == nil && isFixed {
formatGroup = "fixed_layout"
hasFixedLayout = true
} else {
formatGroup = "reflowable"
isReflowable = true
}
case ".mobi", ".azw", ".azw3", ".fb2", ".txt":
formatGroup = "reflowable"
isReflowable = true
case ".pdf", ".djvu":
formatGroup = "fixed_layout"
hasFixedLayout = true
case ".cbz", ".cbr", ".cb7", ".cbt":
formatGroup = "comic_archive"
hasFixedLayout = true
default:
formatGroup = "unknown"
epubIsFixedLayout = fixedErr == nil && isFixed
}
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
err = s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: createdItem.ID,
FormatGroup: formatGroup,
@@ -3170,8 +3152,34 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
_, err = s.db.UpdateMediaItem(ctx, params)
return err
if _, err = s.db.UpdateMediaItem(ctx, params); err != nil {
return err
}
// Format flags are file-derived facts, not user metadata: refresh them on
// every update so a force rescan (the admin's backfill tool) can
// reclassify rows created before a classifyFormatGroup arm existed.
// Mirrors the creation path above.
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
}
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
mimeType := s.getMimeType(path)
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: existing.ID,
FormatGroup: formatGroup,
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
IsReflowable: pgtype.Bool{Bool: isReflowable, Valid: true},
HasFixedLayout: pgtype.Bool{Bool: hasFixedLayout, Valid: true},
TotalCharacters: pgtype.Int8{Int64: metadata.TotalCharacters, Valid: metadata.TotalCharacters > 0},
ChapterCount: pgtype.Int4{Int32: metadata.ChapterCount, Valid: metadata.ChapterCount > 0},
}); err != nil {
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
}
return nil
}
// RescanMediaItem re-extracts metadata for a single media item and updates it.
@@ -3880,6 +3888,28 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
}
}
// classifyFormatGroup maps a lowercased file extension to the format
// group and layout flags stored on the media item. `.epub` is passed in
// with its detected fixed-layout flag because that check needs the file
// itself.
func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string, isReflowable, hasFixedLayout bool) {
switch ext {
case ".epub":
if epubIsFixedLayout {
return "fixed_layout", false, true
}
return "reflowable", true, false
case ".mobi", ".azw", ".azw3", ".fb2", ".txt", ".docx", ".rtf", ".pdb":
return "reflowable", true, false
case ".pdf", ".djvu":
return "fixed_layout", false, true
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive", false, true
default:
return "unknown", false, false
}
}
// extractHashInfo calculates hash and extracts OPF identifiers for a file
func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo, error) {
// Calculate SHA-256
+33
View File
@@ -119,3 +119,36 @@ func TestProcessDirtyDirectories_CollectsReadyDirs(t *testing.T) {
scanner.dirtyDirsMu.RUnlock()
assert.Equal(t, 0, count, "Ready directory should be removed from dirty list")
}
func TestClassifyFormatGroup(t *testing.T) {
tests := []struct {
name string
ext string
epubFixed bool
group string
reflow bool
fixed bool
}{
{"EPUB reflowable", ".epub", false, "reflowable", true, false},
{"EPUB fixed-layout", ".epub", true, "fixed_layout", false, true},
{"MOBI", ".mobi", false, "reflowable", true, false},
{"FB2", ".fb2", false, "reflowable", true, false},
{"TXT", ".txt", false, "reflowable", true, false},
{"DOCX (was unknown before the fix)", ".docx", false, "reflowable", true, false},
{"RTF", ".rtf", false, "reflowable", true, false},
{"PDB (PalmDOC)", ".pdb", false, "reflowable", true, false},
{"PDF", ".pdf", false, "fixed_layout", false, true},
{"DJVU", ".djvu", false, "fixed_layout", false, true},
{"CBZ", ".cbz", false, "comic_archive", false, true},
{"unrecognized extension", ".xyz", false, "unknown", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
group, reflow, fixed := classifyFormatGroup(tt.ext, tt.epubFixed)
if group != tt.group || reflow != tt.reflow || fixed != tt.fixed {
t.Errorf("classifyFormatGroup(%q, %v) = (%q, %v, %v), want (%q, %v, %v)",
tt.ext, tt.epubFixed, group, reflow, fixed, tt.group, tt.reflow, tt.fixed)
}
})
}
}
+27 -25
View File
@@ -22,20 +22,21 @@ func NewSearchService(db *database.Queries) *SearchService {
// SearchParams contains parameters for unified search
type SearchParams struct {
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
TagsFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
Sort string
Limit int
Offset int
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
PublisherFilter string
TagsFilter string
LanguageFilter string
YearMin int
YearMax int
HasCover pgtype.Bool
SearchQuery string
Sort string
Limit int
Offset int
}
// parseSearchQuery detects quoted strings for exact match search
@@ -69,17 +70,18 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
// Build database parameters - only set valid true if filter
dbParams := database.SearchMediaItemsUnifiedParams{
UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
Sort: pgtype.Text{String: params.Sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
+127 -28
View File
@@ -431,7 +431,9 @@ func firstTextDescendant(n *html.Node) *html.Node {
// textNodeAtRuneOffset walks text nodes under elem in document order and
// returns the node containing the rune offset plus the local offset within
// that node. Offsets beyond the end clamp to the last node.
func textNodeAtRuneOffset(elem *html.Node, offset int) (*html.Node, int) {
// textNodeAtUTF16Offset resolves a crengine text().N offset — UTF-16 code
// units — to (text node, rune offset) within elem's text.
func textNodeAtUTF16Offset(elem *html.Node, offset int) (*html.Node, int) {
if offset < 0 {
offset = 0
}
@@ -441,15 +443,15 @@ func textNodeAtRuneOffset(elem *html.Node, offset int) (*html.Node, int) {
var walk func(*html.Node) bool
walk = func(node *html.Node) bool {
if node.Type == html.TextNode {
length := utf8.RuneCountInString(node.Data)
length := utf16Len(node.Data)
if remaining < length {
target = node
local = remaining
local = utf16ToRuneIndex(node.Data, remaining)
return true
}
remaining -= length
target = node
local = length
local = utf8.RuneCountInString(node.Data)
return false
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
@@ -507,7 +509,7 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
var textNode *html.Node
var localOffset int
if xp.CharOffset > 0 {
textNode, localOffset = textNodeAtRuneOffset(elem, xp.CharOffset)
textNode, localOffset = textNodeAtUTF16Offset(elem, xp.CharOffset)
} else {
textNode = firstTextDescendant(elem)
localOffset = 0
@@ -624,7 +626,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
lastBlock = block
off := 0
for _, r := range n.Data {
if unicode.IsSpace(r) {
// Zero-width format characters are line-break opportunities,
// not word characters: devices render them as breaks and
// capture plain spaces, so they must not fuse words in the
// search stream. ZWJ (U+200D) is exempt — it glues emoji
// sequences.
if unicode.IsSpace(r) || r == '\u200b' || r == '\u200c' || r == '\ufeff' {
pendingSpace = true
} else {
if pendingSpace && len(runes) > 0 {
@@ -642,9 +649,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
if strings.TrimSpace(n.Data) != "" {
appendNode(n)
}
// Whitespace-only nodes still participate: a space carried in
// its own inline span (<span class="sp"> </span>) is a real
// word separator, and skipping it fuses adjacent words in the
// search stream. appendNode turns such nodes into pending
// space without emitting anything.
appendNode(n)
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
@@ -1058,24 +1068,96 @@ func indexChildNodes(parent *html.Node) []indexedNode {
return nodes
}
func findTextChunkIndex(parent *html.Node, textNode *html.Node) (int, int) {
// Character-offset currency policy: the EPUB CFI spec and crengine both
// count UTF-16 code units (JavaScript `.length` semantics — what foliate,
// readium, KOReader and Kobo clients all observe), so every offset that
// CROSSES the wire — CFI terminals, CRE text() offsets, the served
// char_offset handle — is UTF-16. Internal arithmetic (book-level
// character_offset, percentage fractions) stays rune-based, consistent
// with TotalCharacters. These helpers convert at the boundaries; for
// all-BMP text the two currencies are identical, so ASCII books are
// unaffected.
// utf16Len returns the UTF-16 code-unit length of s.
func utf16Len(s string) int {
n := 0
for _, r := range s {
if r >= 0x10000 {
n += 2
} else {
n++
}
}
return n
}
// utf16ToRuneIndex converts a UTF-16 code-unit offset within s to a rune
// index (clamped to len(runes)).
func utf16ToRuneIndex(s string, u16 int) int {
if u16 <= 0 {
return 0
}
units := 0
i := 0
for _, r := range s {
if units >= u16 {
return i
}
if r >= 0x10000 {
units += 2
} else {
units++
}
i++
}
return i
}
// runeToUTF16Index converts a rune index within s to a UTF-16 code-unit
// offset (clamped to the string's unit length).
func runeToUTF16Index(s string, runeIdx int) int {
if runeIdx <= 0 {
return 0
}
units := 0
i := 0
for _, r := range s {
if i >= runeIdx {
return units
}
if r >= 0x10000 {
units += 2
} else {
units++
}
i++
}
return units
}
// findTextChunk locates the indexed text chunk containing textNode and
// returns its chunk index, the rune offset of the node within the chunk,
// and the chunk text up to and including the node (for UTF-16 conversion
// of chunk-relative offsets).
func findTextChunk(parent *html.Node, textNode *html.Node) (int, int, string) {
indexed := indexChildNodes(parent)
for i, node := range indexed {
if node.isTextChunk() {
for j, tn := range node.textChunk {
var sb strings.Builder
chunkOffset := 0
for _, tn := range node.textChunk {
if tn == textNode {
chunkOffset := 0
for k := 0; k < j; k++ {
chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data)
}
return i, chunkOffset
return i, chunkOffset, sb.String() + textNode.Data
}
chunkOffset += utf8.RuneCountInString(tn.Data)
sb.WriteString(tn.Data)
}
}
}
return -1, 0
return -1, 0, ""
}
func findElementCFIIndex(parent *html.Node, element *html.Node) int {
indexed := indexChildNodes(parent)
for i, node := range indexed {
@@ -1110,15 +1192,17 @@ func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, erro
return "", fmt.Errorf("text node has no parent")
}
chunkIdx, chunkOffset := findTextChunkIndex(parent, textNode)
chunkIdx, chunkOffset, chunkText := findTextChunk(parent, textNode)
if chunkIdx == -1 {
return "", fmt.Errorf("text node not found in parent's indexed children")
}
// The CFI terminal offset is UTF-16 code units (spec currency); the
// internal charOffset is runes. Convert over the chunk text.
totalOffset := chunkOffset + charOffset
var parts []string
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, totalOffset))
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, runeToUTF16Index(chunkText, totalOffset)))
current := parent
for current != nil {
@@ -1245,9 +1329,16 @@ func countTextChars(n *html.Node) int {
return count
}
// countTextCharsBefore counts the text characters preceding target within
// its document: preceding siblings at each ancestor level, strictly
// EXCLUDING target's own subtree (callers add the in-node offset
// separately). Starting the walk at target itself — or recursing after
// counting the current node's subtree — would re-count the accumulated
// document once per ancestor level and multiply the result by the node's
// depth.
func countTextCharsBefore(target *html.Node) int {
count := 0
for c := target; c != nil; c = c.PrevSibling {
for c := target.PrevSibling; c != nil; c = c.PrevSibling {
count += countTextChars(c)
}
if target.Parent != nil {
@@ -1519,24 +1610,31 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
entry := indexed[lastStep.Index]
if entry.isTextChunk() {
textOffset := 0
// The CFI terminal offset arrives in UTF-16 code units (spec
// currency — foliate/readium/KOReader/Kobo all emit UTF-16).
// Walk the chunk in UTF-16 units, then convert the hit position
// to the internal rune offset.
u16Remaining := 0
if lastStep.HasOffset {
textOffset = lastStep.Offset
u16Remaining = lastStep.Offset
}
var targetNode *html.Node
remainingOffset := textOffset
runeIntoTarget := 0
for _, tn := range entry.textChunk {
textLen := utf8.RuneCountInString(tn.Data)
if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) {
units := utf16Len(tn.Data)
if u16Remaining < units || (u16Remaining == units && targetNode == nil) {
targetNode = tn
runeIntoTarget = utf16ToRuneIndex(tn.Data, u16Remaining)
break
}
remainingOffset -= textLen
u16Remaining -= units
targetNode = tn
runeIntoTarget = utf8.RuneCountInString(tn.Data)
}
if targetNode == nil && len(entry.textChunk) > 0 {
targetNode = entry.textChunk[len(entry.textChunk)-1]
runeIntoTarget = utf8.RuneCountInString(targetNode.Data)
}
parent := targetNode.Parent
@@ -1547,7 +1645,7 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
}
totalOffset += countTextChars(c)
}
totalOffset += remainingOffset
totalOffset += runeIntoTarget
return targetNode, totalOffset, nil
}
@@ -1606,7 +1704,8 @@ func buildCREXPointer(spineIndex int, node *html.Node, charOffset int) (string,
xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, ""))
if charOffset > 0 || (node.Type == html.TextNode) {
xpointer += fmt.Sprintf("/text().%d", charOffset)
// crengine counts UTF-16 code units; charOffset is internal runes.
xpointer += fmt.Sprintf("/text().%d", runeToUTF16Index(node.Data, charOffset))
}
return xpointer, nil
+1 -1
View File
@@ -133,7 +133,7 @@ func writeTestEPUB(t *testing.T) string {
}
docs := []spineDoc{
{"doc1.xhtml", "<body><div><p>Chapter one opening page.</p></div></body>"},
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p></div></body>"},
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p><p>The family crest shows a globe \U0001F30D and a rocket \U0001F680 flying onward.</p></div></body>"},
{"doc3.xhtml", "<body><div><p>Chapter three contents.</p></div></body>"},
{"doc4.xhtml", "<body><div><p>Chapter four contents.</p></div></body>"},
{"doc5.xhtml", "<body><div><p>Chapter five contents.</p></div></body>"},
+167
View File
@@ -0,0 +1,167 @@
package sync
// Text-anchored range conversion for device highlights.
//
// Why this exists: CRE xpointers address text as text()[N].offset, but
// crengine's text-node enumeration does not survive to the raw XHTML on
// disk (whitespace runs, entity merges, live-DOM splits), and the trailing
// offset alone lands at a global block offset that is wrong for any anchor
// past the first text node of a block. Converting pos0 and pos1
// independently made it worse: mismatched start/end base paths and
// backwards ranges — highlights that store but never paint (or paint in
// the wrong place) in the web reader.
//
// The device's selection text, however, is a quote of the document. So for
// highlights we anchor BOTH endpoints by locating that quote in the
// flattened document text (whitespace-normalized, crossing block
// boundaries), and use the device locators only as disambiguation hints
// when the quote occurs more than once. Endpoints derived from one match
// are same-spine and ordered by construction — the two properties the
// reader's range-CFI builder needs.
import (
"log"
"regexp"
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
// ConvertHighlightRange resolves a highlight's pos0/pos1 pair into a start
// CFI plus a true range end by locating the selection text in the document.
// It returns nil when the text cannot be located; callers fall back to the
// legacy per-endpoint conversion.
func (c *CFIConverter) ConvertHighlightRange(pos0, pos1, contextText string, storedPercentage float64) *ConversionResult {
if !IsCREXPointer(pos0) {
return nil
}
xp, err := ParseCREXPointer(pos0)
if err != nil {
return nil
}
doc, href, err := c.getContentDoc(xp.FragmentIndex)
if err != nil {
return nil
}
body := findBody(doc)
if body == nil {
return nil
}
spineIndex := xp.FragmentIndex - 1
// KOReader wraps partial-paragraph captures in curly braces; they are
// wire markers, not document text. Zero-width format characters in the
// capture are break opportunities the device rendered — normalize to
// spaces so both sides of the match agree.
ctx := normalizeWhitespace(strings.Trim(contextText, "{}"))
ctx = strings.NewReplacer("\u200b", " ", "\u200c", " ", "\ufeff", " ").Replace(ctx)
ctx = normalizeWhitespace(ctx)
if ctx == "" {
return nil
}
runes, poss := flattenDocumentForSearch(body)
text := string(runes)
words := strings.Fields(ctx)
quoted := make([]string, len(words))
for i, w := range words {
quoted[i] = regexp.QuoteMeta(w)
}
re, err := regexp.Compile(strings.Join(quoted, `\s+`))
if err != nil {
return nil
}
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
log.Printf("Bookhoard: highlight range: selection %q not found in %s", truncateForLog(ctx, 60), href)
return nil
}
loc := pickHighlightMatch(matches, text, poss, xp, pos1, body)
if len(matches) > 1 {
log.Printf("Bookhoard: highlight range: %d matches for %q in %s, resolved by device hint", len(matches), truncateForLog(ctx, 40), href)
}
startRune := utf8.RuneCountInString(text[:loc[0]])
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
startPos := poss[startRune]
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
if err != nil || startCFI == "" {
log.Printf("Bookhoard: highlight range: buildCFI(start) failed: %v", err)
return nil
}
endCFI := ""
if endRune > startRune && endRune <= len(poss) {
endPos := poss[endRune-1]
endOff := endPos.off + 1
if n := utf8.RuneCountInString(endPos.node.Data); endOff > n {
endOff = n
}
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
endCFI = ec
}
}
log.Printf("Bookhoard: highlight range %q → %s (precision: exact)", truncateForLog(ctx, 60), startCFI)
return &ConversionResult{
EPUBCFI: startCFI,
EndEPUBCFI: endCFI,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
}
}
// pickHighlightMatch chooses among multiple occurrences of the selection.
// pos1's element path (the end anchor's landing block) is the strongest
// hint: the true occurrence is the one whose extent stays inside that
// block. Without a usable hint, document order wins (first occurrence).
func pickHighlightMatch(matches [][]int, text string, poss []docRunePos, xp *CREXPointer, pos1 string, body *html.Node) []int {
if len(matches) <= 1 {
return matches[0]
}
if xp1, err := ParseCREXPointer(pos1); err == nil && len(xp1.ElementPath) > 0 {
if elem := resolveCREElementPath(body, xp1.ElementPath); elem != nil {
lo, hi := elementFlatRange(poss, elem)
if lo >= 0 {
for _, m := range matches {
s := utf8.RuneCountInString(text[:m[0]])
e := utf8.RuneCountInString(text[:m[1]]) - 1
if s >= lo && e < hi {
return m
}
}
}
}
}
return matches[0]
}
// elementFlatRange returns the [lo, hi) rune range of the flattened stream
// covered by elem's subtree, or (-1, 0) when elem contributes no text.
func elementFlatRange(poss []docRunePos, elem *html.Node) (int, int) {
lo, hi := -1, -1
for i, p := range poss {
if isDescendantNode(p.node, elem) {
if lo < 0 {
lo = i
}
hi = i + 1
}
}
if lo < 0 {
return -1, 0
}
return lo, hi
}
func isDescendantNode(n, ancestor *html.Node) bool {
for p := n.Parent; p != nil; p = p.Parent {
if p == ancestor {
return true
}
}
return false
}
+379
View File
@@ -0,0 +1,379 @@
package sync
// Manual repro harness for the CRE→CFI highlight conversion defects found in
// "Clean" (Rosser K Powitzky): 28 device highlights whose stored web CFIs are
// structurally inconsistent (mismatched start/end base paths, backwards
// ranges). This test mirrors handlers.convertHighlightPositions exactly —
// pos0 converted WITH the selection text as context, pos1 WITHOUT any — then
// reports the result alongside the CFIs stored at ingest time and a
// ground-truth oracle computed by locating the selection text in the document
// itself. Skips silently when the book/export are not on this machine, like
// cfi_verify_manual_test.go.
import (
"fmt"
"encoding/json"
"os"
"strings"
"testing"
"golang.org/x/net/html"
)
const (
reproExport = "/home/nymusicman/Code/bookhoard/export/media_highlights.json"
reproEpub = "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Rosser K Powitzky/Clean_ How the Jewish Roots of Atone (77)/Clean_ How the Jewish Roots of - Rosser K Powitzky.epub"
)
type reproRow struct {
ID string `json:"id"`
StartPosition string `json:"start_position"`
EndPosition string `json:"end_position"`
StoredStart string `json:"epubcfi_start"`
StoredEnd string `json:"epubcfi_end"`
SelectionText string `json:"selection_text"`
PctStart float64 `json:"percentage_start"`
}
// webUsable mirrors handlers.webUsableCFI (unexported there; cannot import
// the handlers package from here without a cycle).
func webUsable(precision, cfi string) string {
if precision != "structural" && precision != "exact" {
return ""
}
if !strings.HasPrefix(cfi, "epubcfi(") || !strings.HasSuffix(cfi, ")") {
return ""
}
return cfi
}
// reproExtendCFIByLength mirrors handlers.extendCFIByLength: advance the
// trailing character offset of a point CFI by the UTF-16 length of text.
func reproExtendCFIByLength(cfi, text string) string {
if cfi == "" || text == "" {
return ""
}
if !strings.HasSuffix(cfi, ")") {
return ""
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return cfi
}
var off int
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return cfi
}
return inner[:idx+1] + itoa(off+utf16Len(text)) + ")"
}
// cfiBase splits "epubcfi(...)" into (base, offset): base is everything
// before the final ":N" character-offset terminator.
func cfiBase(cfi string) (string, int, bool) {
if !strings.HasSuffix(cfi, ")") {
return "", 0, false
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return inner, 0, true
}
off := 0
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return inner, 0, true
}
return inner[:idx], off, true
}
func fmtSscanInt(s string, out *int) (int, error) {
n := 0
ok := false
for _, r := range s {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
ok = true
}
if !ok {
return 0, errReproNoInt
}
*out = n
return len(s), nil
}
var errReproNoInt = errReproNoIntErr{}
type errReproNoIntErr struct{}
func (errReproNoIntErr) Error() string { return "no integer" }
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// oracleCFIs locates the selection text in the document named by the start
// xpointer's fragment and returns ground-truth (startCFI, endCFI, spineIndex).
// Whitespace runs are collapsed on both sides before matching, so device
// text with reflowed newlines still lands.
func oracleCFIs(t *testing.T, conv *CFIConverter, row reproRow) (string, string, string) {
xp, err := ParseCREXPointer(row.StartPosition)
if err != nil {
return "", "", "unparseable start xpointer"
}
doc, href, err := conv.getContentDoc(xp.FragmentIndex)
if err != nil {
return "", "", "content doc missing: " + err.Error()
}
body := findBody(doc)
if body == nil {
return "", "", "no body"
}
// Flatten every text node in document order.
var segs []textSegment
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
segs = append(segs, textSegment{node: n, runes: []rune(n.Data)})
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
var flat []rune
for _, s := range segs {
flat = append(flat, s.runes...)
}
needle := []rune(normalizeWhitespace(row.SelectionText))
if len(needle) == 0 {
return "", "", "empty selection text"
}
// Collapse whitespace in `flat` while mapping each kept rune back to a
// flat index.
var norm []rune
var normToFlat []int
lastWS := false
for i, r := range flat {
isWS := r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ' ' || r == '​'
if isWS {
if lastWS {
continue
}
norm = append(norm, ' ')
normToFlat = append(normToFlat, i)
lastWS = true
continue
}
norm = append(norm, r)
normToFlat = append(normToFlat, i)
lastWS = false
}
// Trim wrapped-quote artifacts KOReader adds to partial-paragraph text.
trimmed := needle
for len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '}') {
trimmed = trimmed[1:]
}
for len(trimmed) > 0 && (trimmed[len(trimmed)-1] == '{' || trimmed[len(trimmed)-1] == '}') {
trimmed = trimmed[:len(trimmed)-1]
}
pos := indexRunes(norm, trimmed)
if pos < 0 {
return "", "", "selection text not found in doc " + href
}
flatStart := normToFlat[pos]
flatEnd := normToFlat[pos+len(trimmed)-1] + 1 // end is exclusive
nodeAt := func(flatIdx int) (*html.Node, int) {
acc := 0
for _, s := range segs {
n := len(s.runes)
if flatIdx < acc+n {
return s.node, flatIdx - acc
}
acc += n
}
return nil, 0
}
startNode, startOff := nodeAt(flatStart)
endNode, endOff := nodeAt(flatEnd)
if startNode == nil || endNode == nil {
return "", "", "flat index out of range"
}
spineIndex := xp.FragmentIndex - 1
startCFI, err := buildCFI(spineIndex, startNode, startOff)
if err != nil {
return "", "", "buildCFI start: " + err.Error()
}
endCFI, err := buildCFI(spineIndex, endNode, endOff)
if err != nil {
return "", "", "buildCFI end: " + err.Error()
}
return startCFI, endCFI, ""
}
func indexRunes(hay, needle []rune) int {
if len(needle) == 0 || len(needle) > len(hay) {
return -1
}
for i := 0; i+len(needle) <= len(hay); i++ {
match := true
for j := range needle {
if hay[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func TestManualCleanRepro(t *testing.T) {
raw, err := os.ReadFile(reproExport)
if err != nil {
t.Skipf("export json not present: %v", err)
}
if _, err := os.Stat(reproEpub); err != nil {
t.Skipf("book epub not present: %v", err)
}
var rows []reproRow
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatalf("unmarshal export: %v", err)
}
conv := cachedConverter(reproEpub)
stats := map[string]int{}
for _, row := range rows {
// Mirror handlers.convertHighlightPositions (text-anchored primary
// path, legacy per-endpoint fallback).
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
startLoc = ConvertToCanonical(LocatorSourceKOReader, row.StartPosition, row.PctStart, row.SelectionText, "reflowable", reproEpub, "")
startCFI = webUsable(startLoc.Precision, startLoc.CFI)
endCFI = ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = startLoc.EndCFI
}
if endCFI == "" && row.EndPosition != "" {
endLoc := ConvertToCanonical(LocatorSourceKOReader, row.EndPosition, row.PctStart, "", "reflowable", reproEpub, "")
endCFI = webUsable(endLoc.Precision, endLoc.CFI)
}
if endCFI == "" && startCFI != "" && row.SelectionText != "" {
endCFI = reproExtendCFIByLength(startCFI, row.SelectionText)
}
}
oracleStart, oracleEnd, oracleNote := oracleCFIs(t, conv, row)
stats["oracleMiss:"+statusOr(oracleNote)]++
cls := func(a, b string) string {
ba, oa, okA := cfiBase(a)
bb, ob, okB := cfiBase(b)
switch {
case !okA || !okB || a == "" || b == "":
return "missing"
case ba != bb:
return "diff-base"
case ob <= oa:
return "backwards"
default:
return "healthy"
}
}
cur := cls(startCFI, endCFI)
sto := cls(row.StoredStart, row.StoredEnd)
ora := cls(oracleStart, oracleEnd)
stats["current:"+cur]++
stats["stored:"+sto]++
stats["oracle:"+ora]++
if os.Getenv("REPRO_VERBOSE") != "" || cur != "healthy" || sto != "healthy" {
t.Logf("row %s", row.ID[:8])
t.Logf(" raw: %s -> %s", row.StartPosition, row.EndPosition)
t.Logf(" ctx: %q", truncateRunes(row.SelectionText, 60))
t.Logf(" stored: %-38s %-38s [%s]", row.StoredStart, row.StoredEnd, sto)
t.Logf(" current: %-38s %-38s [%s]", startCFI, endCFI, cur)
t.Logf(" oracle: %-38s %-38s [%s] %s", oracleStart, oracleEnd, ora, oracleNote)
}
}
t.Logf("summary: %v", stats)
// REPRO_EMIT_SQL=<path>: emit UPDATE statements carrying the NEW
// converter output into the database rows this export came from
// (one-off healing of rows written by the broken converter).
if out := os.Getenv("REPRO_EMIT_SQL"); out != "" {
var b strings.Builder
b.WriteString("BEGIN;\n")
for _, row := range rows {
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
continue // leave untouched; legacy fallback rows keep old CFIs
}
b.WriteString(fmt.Sprintf(
"UPDATE media_highlights SET epubcfi_start=%s, epubcfi_end=%s WHERE id='%s';\n",
pgQuote(startCFI), pgQuote(endCFI), row.ID,
))
}
b.WriteString("COMMIT;\n")
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
t.Fatalf("write sql: %v", err)
}
t.Logf("wrote %s", out)
}
}
func pgQuote(s string) string {
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func statusOr(s string) string {
if s == "" {
return "found"
}
return s
}
+349
View File
@@ -0,0 +1,349 @@
package sync
// Ingest-side position authority for reading progress.
//
// Clients submit (percentage, context_text, epubcfi). The epubcfi is a
// standard wrapped CFI — epubcfi(/6/N!/…) — resolvable against the same
// document this package parses, so every submission is INDEPENDENTLY
// verified: the text at the resolved anchor is extracted and compared
// with the submitted context. A mismatch (or an unresolvable anchor)
// heals the position by text search, with the submitted percentage
// disambiguating repeated phrases, instead of trusting a client-side
// projection. This keeps buggy clients from poisoning stored positions:
// a resolver that silently returns "wherever I'm currently scrolled"
// fails the context check and gets healed to the true location.
//
// The anchor's block element is also derived as a cssSelector plus a
// block-relative character offset and served back — the readium-native
// handle clients scroll to, so they never have to parse or trust CFIs
// themselves.
//
// Note: readium-based clients number their readingOrder excluding
// linear="no" spine items, while this package's spine index follows the
// OPF spine as written. The spine index is therefore internal-only;
// client-facing responses carry the anchor document's href instead.
import (
"fmt"
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
// IsConvertibleFormat reports whether a format group uses standard CFIs
// as its structural locator currency (i.e. whether CFI verification and
// cssSelector derivation apply to it).
func IsConvertibleFormat(formatGroup string) bool {
return isConvertible(string(formatGroup))
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
// anchorBlock returns the nearest non-inline (block-level) ancestor of a
// resolved text/element node — the server-side equivalent of the
// readers' computed-style block walk.
func anchorBlock(node *html.Node) *html.Node {
if node == nil {
return nil
}
if node.Type == html.ElementNode && !isInlineFormatting(node) {
return node
}
return findBlockParent(node)
}
// blockContextText extracts the normalized text from (node, runeOff) to
// the end of the anchor block — the same excerpt rule the readers use for
// context_text, ≤100 chars.
func blockContextText(block, node *html.Node, runeOff int) string {
segments := collectInlineText(block)
var sb strings.Builder
started := false
for _, seg := range segments {
if !started && seg.node == node {
started = true
runes := []rune(string(seg.runes))
if runeOff < len(runes) {
sb.WriteString(string(runes[runeOff:]))
}
continue
}
if started {
sb.WriteString(string(seg.runes))
}
}
return truncateRunes(normalizeWhitespace(sb.String()), 100)
}
// contextMatches reports whether a server-extracted context and a client-
// submitted context describe the same anchor. Both are suffixes of the
// same block text when the anchors share a block, so containment in
// either direction verifies; empty or very short contexts never match.
func contextMatches(serverCtx, submitted string) bool {
s := truncateRunes(normalizeWhitespace(submitted), 100)
t := truncateRunes(normalizeWhitespace(serverCtx), 100)
if s == "" || t == "" {
return false
}
short, long := s, t
if len([]rune(short)) > len([]rune(long)) {
short, long = long, short
}
if len([]rune(short)) < 12 {
return false
}
return strings.Contains(long, short)
}
// cssSelectorFor mirrors the readers' selOf: a body-relative
// tag:nth-child(k) chain (k = 1-based position among element siblings).
func cssSelectorFor(block *html.Node) string {
var segs []string
n := block
for n != nil && n.Type == html.ElementNode && n.Data != "body" {
k := 1
sib := n.Parent.FirstChild
for sib != nil && sib != n {
if sib.Type == html.ElementNode {
k++
}
sib = sib.NextSibling
}
segs = append([]string{n.Data + ":nth-child(" + fmt.Sprintf("%d", k) + ")"}, segs...)
n = n.Parent
}
return "body>" + strings.Join(segs, ">")
}
// blockCharOffset computes the offset of (node, runeOff) within the
// concatenated text of its block, in UTF-16 code units — the client-side
// scroll-target currency (JavaScript .length semantics).
func blockCharOffset(block, node *html.Node, runeOff int) int {
segments := collectInlineText(block)
var sb strings.Builder
for _, seg := range segments {
if seg.node == node {
runes := seg.runes
if runeOff < len(runes) {
runes = runes[:runeOff]
}
sb.WriteString(string(runes))
return runeToUTF16Index(sb.String(), utf8.RuneCountInString(sb.String()))
}
sb.WriteString(string(seg.runes))
}
return utf16Len(sb.String())
}
// bookCharOffset computes the book-wide rune offset of (node, runeOff) —
// the reading_progress.character_offset column's currency, consistent with
// TotalCharacters and the percentage derivations.
func bookCharOffset(conv *CFIConverter, spineIndex int, node *html.Node, runeOff int) int {
spine, err := conv.loadSpine()
if err != nil {
return 0
}
before := 0
for i := 0; i < spineIndex && i < len(spine.items); i++ {
doc, _, derr := conv.getContentDoc(i + 1)
if derr != nil {
continue
}
if b := findBody(doc); b != nil {
before += countTextChars(b)
}
}
return before + countTextCharsBefore(node) + runeOff
}
// ProgressAnchor is the full server-computed apply handle for a stored
// standard CFI: the anchor block's cssSelector, the character offset
// within that block's text, and the spine document's href.
type ProgressAnchor struct {
CSSSelector string
CharOffset int
Href string
HealedCFI string
Healed bool
HealedPct *float64
}
// VerifyProgressAnchor resolves a client-submitted standard CFI against
// the EPUB, cross-checks the submitted context text, and heals the anchor
// by text search on any mismatch. charOffset is the anchor's block-
// relative UTF-16 offset (the served char_offset handle); bookOffset is
// the anchor's book-wide rune offset (the character_offset column's
// currency) — callers refresh the column from it on every verified save
// so it never goes stale behind the anchor.
func VerifyProgressAnchor(epubPath, epubcfi, contextText string, percentage float64) (finalCFI string, cssSelector string, anchorHref string, charOffset *int, bookOffset *int, healedPct *float64, healed bool, err error) {
finalCFI = epubcfi
anchorHref = ""
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
if err != nil {
cfi, sel, href, off, book, pct, healedFlag, herr := healFromContext(epubPath, contextText, percentage)
return cfi, sel, href, off, book, pct, healedFlag, herr
}
conv := cachedConverter(epubPath)
doc, docHref, err := conv.getContentDoc(spineIndex + 1)
if err != nil {
return healFromContext(epubPath, contextText, percentage)
}
node, runeOff, rerr := resolveCFIToNode(doc, localSteps)
if rerr != nil {
return healFromContext(epubPath, contextText, percentage)
}
anchorHref = docHref
block := anchorBlock(node)
serverCtx := blockContextText(block, node, runeOff)
if contextMatches(serverCtx, contextText) {
off := blockCharOffset(block, node, runeOff)
book := bookCharOffset(conv, spineIndex, node, runeOff)
return finalCFI, cssSelectorFor(block), anchorHref, &off, &book, nil, false, nil
}
// Mismatch: heal by text search.
hCFI, hPct, hSel, hHref, hBook, herr := healAnchorByText(epubPath, contextText, percentage, spineIndex)
if herr != nil {
return finalCFI, "", hHref, nil, nil, nil, false, fmt.Errorf("context mismatch (server %q vs client %q) and heal failed: %w",
truncateRunes(serverCtx, 40), truncateRunes(contextText, 40), herr)
}
hOff := blockCharOffsetFor(epubPath, hCFI)
return hCFI, hSel, hHref, &hOff, &hBook, &hPct, true, nil
}
func healFromContext(epubPath, contextText string, percentage float64) (string, string, string, *int, *int, *float64, bool, error) {
cfi, healedPct, sel, href, book, err := healAnchorByText(epubPath, contextText, percentage, -1)
if err != nil {
return "", "", "", nil, nil, nil, false, err
}
off := blockCharOffsetFor(epubPath, cfi)
return cfi, sel, href, &off, &book, &healedPct, true, nil
}
func healAnchorByText(epubPath, contextText string, percentage float64, spineIndex int) (string, float64, string, string, int, error) {
if epubPath == "" {
return "", 0, "", "", 0, fmt.Errorf("no epub available for text anchoring")
}
conv := cachedConverter(epubPath)
spine, err := conv.loadSpine()
if err != nil {
return "", 0, "", "", 0, err
}
needle := truncateRunes(normalizeWhitespace(contextText), 40)
if len([]rune(needle)) < 12 {
return "", 0, "", "", 0, fmt.Errorf("context too short to anchor")
}
type match struct {
spine int
node *html.Node
off int
}
var matches []match
totalChars := 0
charsBefore := make([]int, len(spine.items))
for i := range spine.items {
doc, _, derr := conv.getContentDoc(i + 1)
if derr != nil {
continue
}
body := findBody(doc)
if body == nil {
continue
}
charsBefore[i] = totalChars
totalChars += countTextChars(body)
if n, runeOff := findTextInNode(body, needle); n != nil {
matches = append(matches, match{spine: i, node: n, off: runeOff})
}
}
if len(matches) == 0 || totalChars <= 0 {
return "", 0, "", "", 0, fmt.Errorf("context not found in book")
}
best := matches[0]
bestDist := -1.0
for _, m := range matches {
frac := (float64(charsBefore[m.spine]) + float64(countTextCharsBefore(m.node)+m.off)) / float64(totalChars)
d := frac - percentage
if d < 0 {
d = -d
}
if bestDist < 0 || d < bestDist {
best = m
bestDist = d
}
}
bookOff := charsBefore[best.spine] + countTextCharsBefore(best.node) + best.off
healedPct := float64(bookOff) / float64(totalChars)
cfi, err := buildCFI(best.spine, best.node, best.off)
if err != nil {
return "", 0, "", "", 0, err
}
sel := ""
if block := anchorBlock(best.node); block != nil {
sel = cssSelectorFor(block)
}
return cfi, healedPct, sel, spine.items[best.spine].href, bookOff, nil
}
func blockCharOffsetFor(epubPath, cfi string) int {
n, off, _, herr := cfiTextAtAnchorInternal(epubPath, cfi)
if herr != nil {
return 0
}
block := anchorBlock(n)
if block == nil {
return 0
}
return blockCharOffset(block, n, off)
}
// cfiTextAtAnchorInternal resolves a stored standard CFI to its anchor
// text node, rune offset, and containing document.
func cfiTextAtAnchorInternal(epubPath, cfi string) (*html.Node, int, *html.Node, error) {
spineIndex, localSteps, err := parseEPUBCFI(cfi)
if err != nil {
return nil, 0, nil, err
}
conv := cachedConverter(epubPath)
doc, _, err := conv.getContentDoc(spineIndex + 1)
if err != nil {
return nil, 0, nil, err
}
node, off, err := resolveCFIToNode(doc, localSteps)
return node, off, doc, err
}
// ProgressCSSSelector derives the anchor block's cssSelector from a stored
// standard CFI.
func ProgressCSSSelector(epubPath, epubcfi string) (string, error) {
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
if err != nil {
return "", err
}
conv := cachedConverter(epubPath)
doc, _, err := conv.getContentDoc(spineIndex + 1)
if err != nil {
return "", err
}
node, _, err := resolveCFIToNode(doc, localSteps)
if err != nil {
return "", err
}
block := anchorBlock(node)
if block == nil {
return "", fmt.Errorf("no block ancestor for anchor")
}
return cssSelectorFor(block), nil
}
+22
View File
@@ -0,0 +1,22 @@
package sync
import (
"os"
"testing"
)
func TestManualRealBookAnchor(t *testing.T) {
path := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (1269)/1984 - George Orwell.epub"
if _, err := os.Stat(path); err != nil {
t.Skip("real book not present on this machine")
}
cfi := "epubcfi(/6/2!/4[x1984]/8[_idContainer003]/62/1:456)"
ctx := "was at war with one of these Powers it was generally at peace with the other"
finalCFI, sel, href, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, cfi, ctx, 0.0415)
if err != nil {
t.Fatalf("VerifyProgressAnchor error: %v", err)
}
t.Logf("final=%q\n healed=%v healedPct=%v\n selector=%q href=%q charOff=%v", finalCFI, healed, healedPct, sel, href, charOff)
sel2, err2 := ProgressCSSSelector(path, cfi)
t.Logf("ProgressCSSSelector: %q err=%v", sel2, err2)
}
+248
View File
@@ -0,0 +1,248 @@
package sync
import (
"fmt"
"testing"
)
// The fixture (writeTestEPUB in cfi_converter_test.go): 6 spine docs.
// doc2 = spine index 1 with two paragraphs:
// p1: "The family of Dashwood had long been settled in Sussex."
// p2: "Their estate was large, and their residence was at Norland Park."
// Hand-derived local paths (html→body /4, body→div /4, div→p /4|/6,
// p→text chunk /1):
const (
dashwoodCFI = "epubcfi(/6/4!/4/2/2/1:0)"
dashwoodText = "The family of Dashwood had long been settled in Sussex."
estateCFI = "epubcfi(/6/4!/4/2/4/1:0)"
estateText = "Their estate was large, and their residence was at Norland Park."
dashwoodSelect = "body>div:nth-child(1)>p:nth-child(1)"
estateSelect = "body>div:nth-child(1)>p:nth-child(2)"
)
func TestVerifyProgressAnchorAcceptsExact(t *testing.T) {
path := writeTestEPUB(t)
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if healed {
t.Errorf("exact anchor should not heal")
}
if healedPct != nil {
t.Errorf("exact anchor should not carry a healed percentage")
}
if finalCFI != dashwoodCFI {
t.Errorf("finalCFI = %q, want unchanged %q", finalCFI, dashwoodCFI)
}
if sel != dashwoodSelect {
t.Errorf("cssSelector = %q, want %q", sel, dashwoodSelect)
}
}
func TestVerifyProgressAnchorHealsMismatch(t *testing.T) {
path := writeTestEPUB(t)
// Anchored at p1 but the context is p2's text: the classic
// client-projection bug — the server must heal to the true location.
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, estateText, 0.3)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !healed {
t.Fatalf("expected healing, got none (cfi=%q)", finalCFI)
}
if finalCFI != estateCFI {
t.Errorf("healed CFI = %q, want %q", finalCFI, estateCFI)
}
if healedPct == nil || *healedPct <= 0 {
t.Errorf("healed percentage not recomputed: %v", healedPct)
}
// Healing must converge: re-verifying the healed anchor is a no-op.
finalCFI2, _, _, _, _, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, estateText, 0.3)
if err != nil {
t.Fatalf("re-verify error: %v", err)
}
if healed2 {
t.Errorf("healed anchor should be stable, got healed again to %q (pct %v)", finalCFI2, healedPct2)
}
if finalCFI2 != finalCFI {
t.Errorf("re-verify CFI = %q, want %q", finalCFI2, finalCFI)
}
}
func TestVerifyProgressAnchorHealsUnresolvable(t *testing.T) {
path := writeTestEPUB(t)
// Element index 99 is out of range in doc2 — the anchor cannot resolve.
bogus := "epubcfi(/6/4!/4/2/99/1:0)"
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, bogus, dashwoodText, 0.05)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !healed {
t.Fatal("unresolvable anchor should heal from context")
}
if finalCFI != dashwoodCFI {
t.Errorf("healed CFI = %q, want the Dashwood anchor %q", finalCFI, dashwoodCFI)
}
if healedPct == nil {
t.Errorf("healed percentage not recomputed")
}
}
func TestVerifyProgressAnchorDumbClient(t *testing.T) {
path := writeTestEPUB(t)
// No CFI at all — a client that only knows percentage + context is
// fully supported: the server anchors structurally from the text.
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, "", estateText, 0.3)
_ = charOff
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !healed {
t.Fatal("context-only submission should count as anchored-by-heal")
}
if finalCFI != estateCFI {
t.Errorf("anchored CFI = %q, want %q", finalCFI, estateCFI)
}
if sel != estateSelect {
t.Errorf("cssSelector = %q, want %q", sel, estateSelect)
}
if healedPct == nil {
t.Errorf("percentage not recomputed for context-only anchor")
}
}
func TestContextMatches(t *testing.T) {
block := "The family of Dashwood had long been settled in Sussex."
cases := []struct {
name string
serverCtx string
submitted string
want bool
}{
{"exact", block, block, true},
{"suffix of block", block, "settled in Sussex.", true},
{"block is suffix", "settled in Sussex.", block, true},
{"mid-block substring", block, "Dashwood had long been", true},
{"different text", block, "completely unrelated words here", false},
{"too short", block, "the", false},
{"empty submitted", block, "", false},
{"empty server", "", "some long enough context text", false},
}
for _, tc := range cases {
if got := contextMatches(tc.serverCtx, tc.submitted); got != tc.want {
t.Errorf("contextMatches(%q, %q) = %v, want %v", tc.serverCtx, tc.submitted, got, tc.want)
}
}
}
func TestParseStandardCFIRange(t *testing.T) {
// Web progress CFIs are range CFIs; parse must resolve to the start arm.
spineIndex, localSteps, err := parseEPUBCFI("epubcfi(/6/4!/4/4:0,/4/4:53)")
if err != nil {
t.Fatalf("parse error: %v", err)
}
if spineIndex != 1 {
t.Errorf("spineIndex = %d, want 1", spineIndex)
}
if len(localSteps) != 4 {
t.Fatalf("steps = %d, want 4 (parent + start arm)", len(localSteps))
}
last := localSteps[len(localSteps)-1]
if last.Index != 4 || !last.HasOffset || last.Offset != 53 {
t.Errorf("start-arm step = %+v, want /4:0", last)
}
}
// The emoji in fixture doc2's third paragraph are astral plane runes:
// one rune, two UTF-16 code units. Every wire offset (CFI terminals,
// the served char_offset) must therefore be UTF-16, while the book-wide
// character_offset column stays rune-based.
//
// Case 1: the context starts at rune 31 of the paragraph text ("The
// family crest shows a globe " = 31 BMP runes), so its UTF-16 offset is
// also 31 — the currencies agree.
//
// Case 2: the context starts at rune 33 ("and a rocket ..."), with the
// astral 🌍 (rune 31, units 31-32) BEFORE the offset — the UTF-16 offset
// is 34, one more than the rune offset. That +1 is the whole point of
// the boundary conversion.
//
// Local path: p3 of doc2's div = /4/2/6, text chunk /1.
func TestAstralOffsetCurrency(t *testing.T) {
path := writeTestEPUB(t)
cases := []struct {
name string
context string
wantTerm int
}{
{"before any emoji", "🌍 and a rocket 🚀 flying onward.", 31},
{"after one emoji", "and a rocket 🚀 flying onward.", 34},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
wantCFI := fmt.Sprintf("epubcfi(/6/4!/4/2/6/1:%d)", tc.wantTerm)
finalCFI, sel, _, charOff, bookOff, _, healed, err := VerifyProgressAnchor(path, "", tc.context, 0.3)
if err != nil {
t.Fatalf("heal error: %v", err)
}
if !healed {
t.Fatalf("expected heal from context-only submission")
}
if finalCFI != wantCFI {
t.Errorf("healed CFI = %q, want %q (UTF-16 terminal)", finalCFI, wantCFI)
}
if sel != "body>div:nth-child(1)>p:nth-child(3)" {
t.Errorf("cssSelector = %q", sel)
}
if charOff == nil || *charOff != tc.wantTerm {
t.Errorf("block char_offset = %v, want %d UTF-16 units", charOff, tc.wantTerm)
}
if bookOff == nil || *bookOff <= 0 {
t.Errorf("book offset = %v, want a positive book-wide rune offset", bookOff)
}
// Round trip: the healed CFI must verify exactly, with the
// same UTF-16 block handle and a stable book offset.
finalCFI2, _, _, charOff2, bookOff2, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, tc.context, 0.3)
if err != nil {
t.Fatalf("re-verify error: %v", err)
}
if healed2 || healedPct2 != nil {
t.Errorf("exact round trip should not heal (healed=%v)", healed2)
}
if finalCFI2 != wantCFI {
t.Errorf("re-verified CFI = %q, want %q", finalCFI2, wantCFI)
}
if charOff2 == nil || *charOff2 != tc.wantTerm {
t.Errorf("re-verified block char_offset = %v, want %d", charOff2, tc.wantTerm)
}
if bookOff2 == nil || bookOff == nil || *bookOff2 != *bookOff {
t.Errorf("book offset unstable: %v vs %v", bookOff2, bookOff)
}
})
}
}
// The book-wide offset must order anchors the way the book orders them:
// a later paragraph in the same document has a strictly larger
// character_offset.
func TestBookOffsetOrdering(t *testing.T) {
path := writeTestEPUB(t)
_, _, _, _, book1, _, _, err1 := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
_, _, _, _, book2, _, _, err2 := VerifyProgressAnchor(path, estateCFI, estateText, 0.3)
if err1 != nil || err2 != nil {
t.Fatalf("verify errors: %v %v", err1, err2)
}
if book1 == nil || book2 == nil {
t.Fatalf("book offsets missing: %v %v", book1, book2)
}
if *book2 <= *book1 {
t.Errorf("estate offset %d should exceed dashwood offset %d", *book2, *book1)
}
}
-4
View File
@@ -30,9 +30,7 @@ var mimeTypes = map[string]string{
".fb2": "application/x-fictionbook+xml",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-ms-reader",
".pdb": "application/vnd.palm",
".prc": "application/vnd.palm",
}
@@ -46,9 +44,7 @@ var ReflowableFormats = map[string]bool{
".fb2": true,
".txt": true,
".rtf": true,
".doc": true,
".docx": true,
".lit": true,
".pdb": true,
".prc": true,
}
+32
View File
@@ -125,6 +125,38 @@ func ConvertToCanonical(
}
}
// ConvertHighlightRangeToCanonical is the highlight-specific twin of
// ConvertToCanonical: it anchors a device highlight's pos0/pos1 pair by the
// selection text so both endpoints derive from one document match — same
// spine, ordered — instead of two independent structural landings. Returns
// a "fallback" locator with an empty CFI when the text cannot be located;
// callers fall back to ConvertToCanonical per endpoint.
func ConvertHighlightRangeToCanonical(
epubPath string,
formatGroup string,
pos0 string,
pos1 string,
contextText string,
percentage float64,
) CanonicalLocator {
if !isConvertible(formatGroup) || epubPath == "" {
return CanonicalLocator{CFI: pos0, Precision: "passthrough", Percentage: percentage}
}
if !IsCREXPointer(pos0) {
return CanonicalLocator{CFI: pos0, Precision: "already-standard", Percentage: percentage}
}
result := cachedConverter(epubPath).ConvertHighlightRange(pos0, pos1, contextText, percentage)
if result == nil || result.EPUBCFI == "" {
return CanonicalLocator{CFI: "", Precision: "fallback", Percentage: percentage}
}
return CanonicalLocator{
CFI: result.EPUBCFI,
EndCFI: result.EndEPUBCFI,
Precision: result.Precision,
Percentage: result.Percentage,
}
}
func ConvertFromCanonical(
source LocatorSource,
canonicalCFI string,
+77 -4
View File
@@ -274,17 +274,27 @@ func EstimatedPages(totalCharacters int64) int {
type ProgressService struct {
db *database.Queries
connManager *ConnectionManager
mediaPaths MediaPathResolver
}
// MediaPathResolver resolves a media item's library-relative file path to
// an absolute path, so the progress service can verify submitted anchors
// against the book itself.
type MediaPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
}
func (s *ProgressService) SetMediaPathResolver(r MediaPathResolver) { s.mediaPaths = r }
func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService {
return &ProgressService{db: db, connManager: connManager}
}
type SaveProgressRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Source string
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Source string
DeviceID pgtype.UUID
Percentage *float64
Epubcfi *string
@@ -305,6 +315,16 @@ type SaveProgressRequest struct {
DeviceType string
DeviceName string
Broadcast bool
// ClearLocator declares that this write INTENDS to carry no position
// locator: percentage-only writers (devices whose converter declined,
// Kobo markup percentage syncs, queue entries without CFI). Without it,
// seeding below would re-pair the new percentage with the PREVIOUS
// writer's stale CFI/context — the incoherent row shape that feeds the
// client-side far-anchor restore floor. Locater-less rows are honest:
// percentage restores are approximate, and the next anchor-bearing
// write re-establishes the pair. Request-supplied fields always win.
ClearLocator bool
}
func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequest) (database.ReadingProgress, error) {
@@ -397,6 +417,22 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
params.ReadingMode = pgtype.Text{String: *req.ReadingMode, Valid: true}
}
// Locator coherence: a write that declares no locator must not
// silently re-pair its new percentage with the previous writer's
// stale CFI/context (inherited by the seeding above). Clear them so
// the row is an honest percentage-only position. Request-carried
// fields always win — and a request-carried context survives so the
// verify/heal pass below can mint a fresh anchor from it.
if req.ClearLocator && req.Epubcfi == nil {
params.Epubcfi = pgtype.Text{}
if req.ContextText == nil {
params.ContextText = pgtype.Text{}
}
if req.CharacterOffset == nil {
params.CharacterOffset = pgtype.Int8{}
}
}
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
@@ -435,6 +471,43 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
}
}
// Canonical position authority: resolve the submitted CFI against the
// book itself, cross-check the submitted context text, and heal the
// anchor by text search on any mismatch. Clients submit what their
// renderer can reliably observe (percentage + visible text); the
// server owns the structural math and keeps buggy clients from
// poisoning stored positions.
if !isFixed && s.mediaPaths != nil && params.ContextText.Valid &&
isConvertible(string(formatGroup)) && formatGroup != "" {
if path, perr := s.mediaPaths.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
submittedCFI := ""
if params.Epubcfi.Valid {
submittedCFI = params.Epubcfi.String
}
submittedPct := 0.0
if params.Percentage.Valid {
submittedPct = params.Percentage.Float64
}
if finalCFI, _, _, _, bookOff, healedPct, healed, verr := VerifyProgressAnchor(path, submittedCFI, params.ContextText.String, submittedPct); verr != nil {
log.Printf("Bookhoard: progress anchor verify failed for %s: %v", req.MediaItemID.String(), verr)
} else {
if healed || (!params.Epubcfi.Valid && finalCFI != "") {
params.Epubcfi = pgtype.Text{String: finalCFI, Valid: true}
if healedPct != nil {
params.Percentage = pgtype.Float8{Float64: *healedPct, Valid: true}
}
}
// The verified book-wide offset refreshes the column on
// EVERY verified save — not only heals — so it never goes
// stale behind the anchor (and a block-relative offset is
// never written into the book-level column).
if bookOff != nil {
params.CharacterOffset = pgtype.Int8{Int64: int64(*bookOff), Valid: true}
}
}
}
}
conflictDetected := false
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
if existing.LastSyncTimestamp.Valid {
+22 -18
View File
@@ -126,29 +126,29 @@ func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error {
}
type HighlightUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
EpubcfiStart string
EpubcfiEnd string
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
EpubcfiStart string
EpubcfiEnd string
PercentageStart float64
PercentageEnd float64
Source string
DeviceSyncData map[string]interface{}
Source string
DeviceSyncData map[string]interface{}
}
type NoteUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
Source string
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
Source string
DeviceSyncData map[string]interface{}
}
@@ -443,6 +443,10 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
if v, ok := syncData["epubcfi"].(string); ok {
req.Epubcfi = &v
}
// Entries without a CFI are percentage-only by intent: declare it
// so the service clears any inherited stale anchor instead of
// re-pairing it with this percentage.
req.ClearLocator = req.Epubcfi == nil
if v, ok := syncData["context_text"].(string); ok {
req.ContextText = &v
}
+10
View File
@@ -0,0 +1,10 @@
package version
// Version is the app version. It defaults to "dev" for local builds and is
// overridden at build time in release images via:
//
// go build -ldflags "-X bookhoard/internal/version.Version=v1.5"
//
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
// Docker build arg, which the Dockerfile forwards to this flag.
var Version = "dev"
+10 -2
View File
@@ -12,16 +12,24 @@
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#e16530a",
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#b7597d6",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"dompurify": "^3.4.16",
"fflate": "^0.8.3",
"highlight.js": "^11.11.1",
"htmx.org": "^2.0.8",
"lunr": "^2.3.9"
"lunr": "^2.3.9",
"markdown-it": "^15.0.2",
"markdown-it-footnote": "^4.0.0",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@types/markdown-it": "^14.2.0",
"@types/turndown": "^5.0.6",
"autoprefixer": "^10.4.27",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Build a synthetic 'torture test' EPUB exercising every CRE-xpointer -> EPUB-CFI
conversion hazard the server converter can encounter.
Each chapter targets one hazard class; constructs carry comments naming the
hazard. Output: uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub
(or argv[1]). Deterministic output for reproducible tests.
"""
import os
import sys
import zipfile
MIMETYPE = "application/epub+zip"
CONTAINER = """<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
"""
def page(name, title, body):
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head><title>{title}</title><link rel="stylesheet" type="text/css" href="../css/style.css"/></head>
<body>
<!-- chapter: {name} -->
<h1 id="{name}-h">{title}</h1>
{body}
</body>
</html>
"""
CHAPTERS = []
# 1. The core bug shape: multi-text-node paragraphs (inline markup splitting text)
CHAPTERS.append(("split-text", "Split Text Nodes", """
<p id="st-p1">In the beginning <span class="verse">1</span> God created the heaven and the earth <span class="verse">2</span> and the earth was without form.</p>
<p id="st-p2">A <em>single</em> sentence with <strong>several</strong> inline <span class="mark">marked</span> spans inside it.</p>
<p id="st-p3">First text node here.<span class="cont">Immediately-following span text.</span>Text that continues after the span.<span class="cont2">And a second span.</span>Tail text node.</p>
<p id="st-p4">Quoted "double" and 'single' marks, an ampersand &amp; entity, and a unicode em—dash.</p>
<div class="group" id="st-group">
<p id="st-p5">Nested group paragraph one <span class="verse">1</span> with more text after the marker.</p>
<p id="st-p6">Nested group paragraph two, plain text only.</p>
</div>
"""))
# 2. Whitespace-only text nodes + comments between elements (sibling-index drift)
CHAPTERS.append(("whitespace-comments", "Whitespace and Comments", """
<ul id="wc-list">
<!-- comment directly inside list -->
<li id="wc-li1">
<p id="wc-p1">Indented markup with whitespace text nodes on every side.</p>
<!-- comment between siblings -->
<p id="wc-p2">Second indented paragraph with irregular internal spacing.</p>
</li>
<!-- another comment -->
<li id="wc-li2">
<p id="wc-p3">Paragraph after multiple comment and whitespace siblings.</p>
</li>
</ul>
<p id="wc-p4">Tail paragraph after the list.</p>
"""))
# 3. Drop caps + footnote anchors (single-char inline splits)
CHAPTERS.append(("dropcaps-footnotes", "Dropcaps and Footnotes", """
<p id="df-p1"><span class="dropcap">O</span>nce upon a time a drop cap split the first word from itself.</p>
<p id="df-p2">A claim requiring support<span class="fnref"><a id="fnref-1" href="#fn-1">1</a></span> and a second claim<span class="fnref"><a id="fnref-2" href="#fn-2">2</a></span> in the same paragraph.</p>
<p id="df-p3">Multiple <a href="#df-p1">internal links</a> interleaved <a href="#df-p2">through text</a> as anchors.</p>
<div class="footnotes" id="df-notes">
<p id="df-fn1"><a id="fn-1" href="#fnref-1">1.</a> The first footnote, body text follows the backlink.</p>
<p id="df-fn2"><a id="fn-2" href="#fnref-2">2.</a> The second footnote with <em>emphasis</em> inside.</p>
</div>
"""))
# 4. Poetry / drama: br-separated lines, speaker spans, line numbers
CHAPTERS.append(("poetry", "Poetry and Drama", """
<p id="po-dramap"><span class="speaker">HAMLET.</span> To be, or not to be, that is the question:<br/>Whether 'tis nobler in the mind to suffer<br/>The slings and arrows of outrageous fortune.</p>
<p id="po-verse1"><span class="line"><span class="lnum">1</span>The curfew tolls the knell of parting day,</span><br/><span class="line"><span class="lnum">2</span>The lowing herd wind slowly o'er the lea,</span><br/><span class="line"><span class="lnum">3</span>The plowman homeward plods his weary way,</span><br/><span class="line"><span class="lnum">4</span>And leaves the world to darkness and to me.</span></p>
"""))
# 5. Deep nesting: quotes in quotes, lists in lists, tables with inline markup
CHAPTERS.append(("nested", "Nested Structures", """
<blockquote id="ne-q1"><p id="ne-q1p1">Outer quotation text begins here <span class="verse">1</span> and continues.</p>
<blockquote id="ne-q2"><p id="ne-q2p1">Inner quotation, one level deeper <em>with emphasis</em> mid-sentence.</p></blockquote>
<p id="ne-q1p2">Outer quotation resumes after the inner block.</p>
</blockquote>
<ul id="ne-list1">
<li id="ne-li1">Level one item
<ul id="ne-list2">
<li id="ne-li2">Level two item
<ul id="ne-list3">
<li id="ne-li3">Level three item with <strong>bold</strong> text.</li>
<li id="ne-li4">Level three sibling item.</li>
</ul>
</li>
</ul>
</li>
<li id="ne-li5">Level one sibling item.</li>
</ul>
<table id="ne-table">
<tr id="ne-tr1"><td id="ne-td1">Cell with <em>inline</em> markup</td><td id="ne-td2">Second cell, plain</td></tr>
<tr id="ne-tr2"><td id="ne-td3">Cell three with a <span class="verse">7</span> marker</td><td id="ne-td4">Cell four</td></tr>
</table>
<dl id="ne-dl"><dt id="ne-dt1">Term one</dt><dd id="ne-dd1">Definition one with text.</dd><dt id="ne-dt2">Term two</dt><dd id="ne-dd2">Definition two with text.</dd></dl>
"""))
# 6. Unicode hazards: surrogate pairs, combining marks, RTL, invisible chars, entities
CHAPTERS.append(("unicode", "Unicode Hazards", """
<p id="un-emoji">Emoji with surrogate pairs: 🎉🚀 and a ZWJ family 👨‍👩‍👧‍👦 plus math 𝛑𝕏 and flags 🇺🇸🇯🇵.</p>
<p id="un-hebrew">Hebrew with niqqud: בְּרֵאשִׁית בָּרָא אֱלֹהִים and a Midrash on דָּבָר מִשְׁפָּט אֱמֶת.</p>
<p id="un-arabic">Arabic RTL with embedded LTR: يبدأ النص العربي هنا ثم 42 ثم ينتهي، مع تشكيل الكلمات المُعرَبة.</p>
<p id="un-combining">Decomposed e + combining acute: é vs precomposed é, and ĉ vs ĉ with combining circumflex.</p>
<p id="un-invisible">Soft hyphen in­side a word, zero&#8201;thin space, zero-width ​space, and trailing no-break space&#160;here.</p>
<p id="un-entities">Ampersand &amp;amp;, less &amp;lt;, greater &amp;gt;, apostrophe &amp;#39;, quote &amp;quot;, all rendered literally.</p>
"""))
# 7. Ruby / CJK
CHAPTERS.append(("ruby", "Ruby Annotations", """
<p id="ru-p1"><ruby>漢字<rt>かんじ</rt></ruby>の<ruby>読<rt>よ</rt></ruby>み<span>方</span>をテストする。</p>
<p id="ru-p2">日本語のテキストには<ruby>難<rt>むずか</rt></ruby>しい<ruby>漢字<rt>かんじ</rt></ruby>がたくさんあります。</p>
<p id="ru-p3">The ruby base 漢 and its annotation かん are separate text nodes in raw XHTML.</p>
"""))
# 8. Giant chapter with heavy phrase repetition (text-search disambiguation)
giant = ['<p id="gi-p0">Opening paragraph of the giant chapter, unique opening sentence.</p>']
for i in range(1, 401):
giant.append(
f'<p id="gi-p{i}">Verse <span class="verse">{i}</span> And it came to pass in those days that a repeated phrase appeared again among the people. '
f'Variation marker alpha-{i}: the sentence continues with unique tail number {i * 7} before ending.</p>'
)
CHAPTERS.append(("giant", "Giant Repeated Chapter", "\n".join(giant)))
# 9. Edge constructs: empties, same-tag adjacency, span-first paragraphs, deep same-tag nesting
deep = "<p id=\"ed-deep\">Deep nesting bottom text.</p>"
for lvl in range(10, 0, -1):
deep = f'<div id="ed-lvl{lvl}">{deep}</div>'
CHAPTERS.append(("edge", "Edge Constructs", f"""
<p id="ed-spanfirst"><span class="lead">Leading span before any direct text.</span> Direct text follows the span.<span class="tail">Tail span.</span></p>
<p id="ed-adjacent"><span class="a">Adjacent</span><span class="b">same-tag</span><span class="c">spans</span> then plain text.</p>
<p id="ed-empty">Text with an empty span <span></span> mid-sentence and an empty emphasis <em></em> after it.</p>
<p id="ed-emptyp"></p>
<div id="ed-onlychild">{deep}</div>
<p id="ed-attrs" data-why="not">Paragraph with odd attribute values like &quot;quotes&quot; and &apos;apostrophes&apos;.</p>
"""))
CSS = """body { margin: 1em; }
.verse, .lnum, .fnref { font-size: 0.7em; vertical-align: super; color: #666; }
.speaker { font-variant: small-caps; }
.dropcap { font-size: 2.5em; font-weight: bold; float: left; line-height: 1; }
.line { display: block; margin-left: 2em; }
blockquote { margin: 1em 2em; border-left: 2px solid #ccc; padding-left: 1em; }
ruby rt { font-size: 0.5em; }
"""
def build(out_path):
files = {}
files["mimetype"] = MIMETYPE
files["META-INF/container.xml"] = CONTAINER
files["css/style.css"] = CSS
manifests, spines = [], []
for idx, (name, title, body) in enumerate(CHAPTERS, start=1):
files[f"OPS/chapters/{name}.xhtml"] = page(name, title, body)
manifests.append(f' <item id="ch{idx}" href="chapters/{name}.xhtml" media-type="application/xhtml+xml"/>')
spines.append(f' <itemref idref="ch{idx}"/>')
nav_items = "\n".join(
f' <li><a href="chapters/{name}.xhtml">{title}</a></li>'
for name, title, _ in CHAPTERS
)
files["OPS/nav.xhtml"] = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Navigation</title></head>
<body>
<nav epub:type="toc" id="toc"><h1>Contents</h1>
<ol>
{nav_items}
</ol>
</nav>
</body>
</html>
"""
nav_manifest = ' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>'
files["OPS/content.opf"] = f"""<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:1f2e3d4c-5b6a-7948-b7c6-d5e4f3a2b1c0</dc:identifier>
<dc:title>CRE CFI Torture Test</dc:title>
<dc:creator>Bookhoard Test</dc:creator>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-10-12T00:00:00Z</meta>
</metadata>
<manifest>
{nav_manifest}
{chr(10).join(manifests)}
</manifest>
<spine>
{chr(10).join(spines)}
</spine>
</package>
"""
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zipfile.ZipFile(out_path, "w") as z:
z.writestr(zipfile.ZipInfo("mimetype"), MIMETYPE, compress_type=zipfile.ZIP_STORED)
for arcname in sorted(files):
if arcname == "mimetype":
continue
z.writestr(arcname, files[arcname], compress_type=zipfile.ZIP_DEFLATED)
print(f"wrote {out_path} ({os.path.getsize(out_path):,} bytes, {len(CHAPTERS)} chapters)")
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
os.path.dirname(__file__), "..",
"uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub")
build(os.path.normpath(out))
+11 -11
View File
@@ -57,7 +57,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 30, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 30, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -113,7 +113,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 65, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 65, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -131,7 +131,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 67, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 67, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -149,7 +149,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 69, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 69, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -162,7 +162,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 71, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 71, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 72, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -193,7 +193,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 77, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 77, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -206,7 +206,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 78, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -219,7 +219,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 80, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 80, Col: 174}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -245,7 +245,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 88, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 88, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -266,7 +266,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 97, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 97, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
+16 -16
View File
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
+35 -35
View File
@@ -65,7 +65,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 42, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 42, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -211,7 +211,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 246, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 246, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -229,7 +229,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 248, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 248, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -251,7 +251,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 253, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 253, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -273,7 +273,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 258, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 258, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -291,7 +291,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 264, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 264, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -312,7 +312,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 274, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -333,7 +333,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 283, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 283, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -408,7 +408,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 305, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 305, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -421,7 +421,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 308, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 308, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -434,7 +434,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"folder_path": "` + folder.FolderPath + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 309, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 309, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -447,7 +447,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 310, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 310, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -478,7 +478,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 320, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 320, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -491,7 +491,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 320, Col: 124}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 320, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -504,7 +504,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 324, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 324, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -517,7 +517,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 331, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -530,7 +530,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 332, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -587,7 +587,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 361, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 361, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -600,7 +600,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 362, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 362, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -613,7 +613,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 364, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 364, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -626,7 +626,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 367, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 367, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -639,7 +639,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 368, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 368, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -667,7 +667,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var26 templ.SafeURL
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 377, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 377, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -694,7 +694,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 382, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 382, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -721,7 +721,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 391, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 391, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
@@ -734,7 +734,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 392, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 392, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
@@ -747,7 +747,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 393, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 393, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
@@ -768,7 +768,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 401, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 401, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
@@ -781,7 +781,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 402, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 402, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
@@ -839,7 +839,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 417, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 417, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -857,7 +857,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 423, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 423, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
@@ -888,7 +888,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 436, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 436, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
@@ -909,7 +909,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 441, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 441, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
@@ -927,7 +927,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 448, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 448, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
@@ -940,7 +940,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 449, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 449, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
+15 -15
View File
@@ -70,7 +70,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 36, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -97,7 +97,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 45, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 54, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -166,7 +166,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 70, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -179,7 +179,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 73, Col: 98}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -192,7 +192,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 74, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -205,7 +205,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 76, Col: 140}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -218,7 +218,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -231,7 +231,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 139}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -244,7 +244,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 149}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -262,7 +262,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 84, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -280,7 +280,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 86, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -298,7 +298,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 88, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -321,7 +321,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 95, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -334,7 +334,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
+20 -1
View File
@@ -1,6 +1,9 @@
package templates
import "fmt"
import (
"bookhoard/internal/version"
"fmt"
)
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
<!DOCTYPE html>
@@ -111,6 +114,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
@ScanSettingsSection(scanSettings)
@TunableSettingsSection(liveGroups, false)
@TunableSettingsSection(restartGroups, true)
@AboutSection()
</div>
</main>
</body>
@@ -255,3 +259,18 @@ templ TunableSettingRow(e SettingEntry) {
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
</div>
}
// AboutSection shows build metadata, rendered at the bottom of the System
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
// list below.
templ AboutSection() {
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-4">
@Icon("info", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">About</h3>
</div>
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Version:</span> { version.Version }</p>
</div>
</div>
}
+78 -18
View File
@@ -8,7 +8,10 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "fmt"
import (
"bookhoard/internal/version"
"fmt"
)
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
@@ -67,7 +70,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 31, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 34, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -93,7 +96,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 45, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 48, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -370,7 +373,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 106, Col: 145}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 109, Col: 145}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -383,7 +386,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 107, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -396,7 +399,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 108, Col: 152}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 152}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -418,6 +421,10 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AboutSection().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -472,7 +479,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 154, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 158, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -564,7 +571,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 194, Col: 112}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 198, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -622,7 +629,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 209, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -640,7 +647,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 211, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 215, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -658,7 +665,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 213, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 217, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -676,7 +683,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 219, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -689,7 +696,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 223, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 227, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -732,7 +739,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 233, Col: 20}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 237, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -750,7 +757,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 235, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 239, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -769,7 +776,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 238, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 242, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -792,7 +799,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 246, Col: 20}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 250, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -818,7 +825,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 255, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 259, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -832,4 +839,57 @@ func TunableSettingRow(e SettingEntry) templ.Component {
})
}
// AboutSection shows build metadata, rendered at the bottom of the System
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
// list below.
func AboutSection() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 273, Col: 134}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</p></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+4 -4
View File
@@ -60,7 +60,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 32, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -81,7 +81,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 39, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -102,7 +102,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 46, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -123,7 +123,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 53, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
+10 -10
View File
@@ -57,7 +57,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 42, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 50, Col: 97}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -101,7 +101,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 59, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 77, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -137,7 +137,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 78, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -170,7 +170,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 87, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 92, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -201,7 +201,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 98, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -240,7 +240,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 117, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -253,7 +253,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 118, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
+217
View File
@@ -0,0 +1,217 @@
package templates
import "bookhoard/internal/handlers"
// AnnotationsBanner is the book-detail slide-out listing every note and
// highlight on the book (objective 7). Rows show a friendly human location
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
// (markdown pipeline identical to the reader's), and offer a per-row jump
// that opens the reader at the annotation's location via ?loc=. Web-only
// export lives at the top: single .md file, or one file per item zipped.
// Formatting is IDENTICAL in both modes:
// location note: "# <friendly label>" header + note source as body
// highlight: "# <highlight text>" header + note source as body
// (no note → header only)
templ AnnotationsBanner(book handlers.MediaDetail) {
<div
id="annotations-banner"
x-cloak
x-show="annotationsBannerOpen"
x-transition:enter="transition-transform duration-200 ease-out"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-150 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto"
style="background: var(--bg-primary); border-left: 1px solid var(--border);"
role="dialog"
aria-label="Annotations"
>
<div class="sticky top-0 px-4 py-3 flex items-center justify-between" style="background: var(--bg-primary); border-bottom: 1px solid var(--border);">
<h3 class="text-lg font-semibold" style="color: var(--text-primary);">Notes &amp; Highlights</h3>
<button
type="button"
@click="hideAnnotationsBanner()"
class="p-2 rounded-lg hover:bg-surface-hover"
style="color: var(--text-secondary);"
aria-label="Close annotations"
>
@Icon("close", "w-5 h-5")
</button>
</div>
<div class="px-4 py-3">
<button
type="button"
id="export-annotations-btn"
:disabled="annotationsBannerLoading || bannerRows.length === 0"
@click="showExportDialog()"
class="w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
style="border-color: var(--border); color: var(--text-primary);"
>
Export all notes and highlights
</button>
<p x-show="annotationsBannerLoading" class="text-sm py-4" style="color: var(--text-secondary);">Loading…</p>
<template x-if="!annotationsBannerLoading && bannerRows.length === 0">
<p class="text-sm py-4" style="color: var(--text-secondary);">
No notes or highlights on this book yet.
</p>
</template>
<!-- Notes first, then highlights — stable ledger order -->
<div class="space-y-2">
<template x-for="row in bannerRows" :key="row.key">
<div class="card p-0 overflow-hidden relative">
<!-- Note indicator: a quiet stroke glyph in the card's
upper-right corner (never left of text) — every
note row, and highlights with an attached note. -->
<svg
x-show="row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)"
class="absolute top-2 right-2 pointer-events-none reader-icon"
style="color: var(--text-secondary);"
width="13" height="13" viewBox="0 0 20 20" aria-hidden="true"
><path d="M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5"></path></svg>
<button
type="button"
class="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover"
:class="(row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)) ? 'pr-8' : ''"
@click="row.expanded = !row.expanded"
>
<span
class="w-1.5 self-stretch rounded-full shrink-0"
:style="{ backgroundColor: row.kind === 'highlight' ? row.color : 'transparent' }"
></span>
<span class="flex-1 min-w-0">
<span
class="block text-sm"
x-text="row.preview"
></span>
<span class="block text-xs mt-0.5" style="color: var(--text-secondary);" x-text="row.label"></span>
</span>
</button>
<!-- Jump · edit (notes + attached highlight notes) · delete
(location notes) — objective 8a -->
<div class="flex items-center justify-between px-4 pb-3 pt-1">
<a
x-show="row.loc"
:href="'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)"
class="text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors"
style="border-color: var(--border); color: var(--accent);"
>
Open reader here ↗
</a>
<div class="flex items-center gap-1">
<button
type="button"
@click="startBannerEdit(row)"
class="text-xs px-2 py-1 rounded-lg hover:bg-surface-hover"
style="color: var(--text-secondary);"
x-text="row.kind === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'"
></button>
<button
type="button"
@click="row.kind === 'note' ? deleteBannerNote(row) : deleteBannerHighlight(row)"
class="text-xs px-2 py-1 rounded-lg hover:bg-surface-hover"
style="color: var(--status-danger);"
>✕ Delete</button>
</div>
</div>
<!-- Expanded rich body (markdown pipeline). Highlight
rows split at the highlight→note boundary: the
selection (bordered in the row color), a divider,
then the note markdown — the break reads on its
own, no label needed. -->
<div x-show="row.expanded" class="px-4 pb-4">
<p
x-show="row.kind === 'highlight'"
class="text-sm"
:style="row.kind === 'highlight' ? 'border-left: 3px solid ' + row.color + '; padding-left: .75rem; color: var(--text-secondary); margin: 0 0 ' + (row.hasNote ? '.75rem' : '0') + ';' : 'color: var(--text-secondary);'"
x-text="row.richSelection"
></p>
<div
x-show="row.kind === 'highlight' && row.hasNote"
class="border-t mt-3 pt-3"
style="border-color: var(--border);"
></div>
<div class="note-rich text-sm" x-html="row.richNote"></div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Note edit dialog (objective 8a): raw markdown source; for
highlight rows the dialog edits the note ATTACHED to the
highlight (never the highlight itself) -->
<div
id="banner-note-dialog"
x-cloak
x-show="bannerEditOpen"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background: rgba(0,0,0,.6);"
>
<div class="max-w-md w-full card p-6">
<h4 class="text-lg font-semibold mb-1" style="color: var(--text-primary);" x-text="bannerEditRow && bannerEditRow.kind === 'highlight' ? (bannerEditRow.hasNote ? 'Edit highlight note' : 'Add note to highlight') : 'Edit note'"></h4>
<p class="text-xs mb-3" style="color: var(--text-secondary);">Markdown supported</p>
<textarea
x-model="bannerEditText"
rows="6"
class="input w-full text-sm"
></textarea>
<div class="flex gap-2 mt-4">
<button
type="button"
@click="saveBannerNote()"
:disabled="!String(bannerEditText || '').trim()"
class="flex-1 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>Save</button>
<button
type="button"
@click="bannerEditOpen = false"
class="flex-1 py-2 text-sm border rounded hover:bg-surface-hover"
style="border-color: var(--border); color: var(--text-secondary);"
>Cancel</button>
</div>
</div>
</div>
<!-- Export format dialog -->
<div
id="annotations-export-dialog"
x-cloak
x-show="annotationsExportOpen"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background: rgba(0,0,0,.6);"
>
<div class="max-w-sm w-full card p-6">
<h4 class="text-lg font-semibold mb-1" style="color: var(--text-primary);">Export annotations</h4>
<p class="text-sm mb-4" style="color: var(--text-secondary);">Notes and highlights, as markdown files.</p>
<div class="space-y-2">
<button
type="button"
@click="exportAnnotations('single')"
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
style="border-color: var(--border); color: var(--text-primary);"
>
Single file — everything in one .md
</button>
<button
type="button"
@click="exportAnnotations('multi')"
class="w-full py-2 px-4 text-sm text-left rounded-lg border hover:bg-surface-hover transition-colors"
style="border-color: var(--border); color: var(--text-primary);"
>
One file per item — zipped
</button>
</div>
<button
type="button"
@click="hideExportDialog()"
class="w-full py-2 mt-3 text-sm rounded-lg hover:bg-surface-hover"
style="color: var(--text-secondary);"
>
Cancel
</button>
</div>
</div>
}
+61
View File
@@ -0,0 +1,61 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "bookhoard/internal/handlers"
// AnnotationsBanner is the book-detail slide-out listing every note and
// highlight on the book (objective 7). Rows show a friendly human location
// label (never a raw cfi:epubcfi(…) string), expand to the full rich markup
// (markdown pipeline identical to the reader's), and offer a per-row jump
// that opens the reader at the annotation's location via ?loc=. Web-only
// export lives at the top: single .md file, or one file per item zipped.
// Formatting is IDENTICAL in both modes:
//
// location note: "# <friendly label>" header + note source as body
// highlight: "# <highlight text>" header + note source as body
// (no note → header only)
func AnnotationsBanner(book handlers.MediaDetail) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"annotations-banner\" x-cloak x-show=\"annotationsBannerOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"fixed right-0 top-0 bottom-0 w-full max-w-md z-50 shadow-2xl overflow-y-auto\" style=\"background: var(--bg-primary); border-left: 1px solid var(--border);\" role=\"dialog\" aria-label=\"Annotations\"><div class=\"sticky top-0 px-4 py-3 flex items-center justify-between\" style=\"background: var(--bg-primary); border-bottom: 1px solid var(--border);\"><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary);\">Notes &amp; Highlights</h3><button type=\"button\" @click=\"hideAnnotationsBanner()\" class=\"p-2 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close annotations\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "w-5 h-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</button></div><div class=\"px-4 py-3\"><button type=\"button\" id=\"export-annotations-btn\" :disabled=\"annotationsBannerLoading || bannerRows.length === 0\" @click=\"showExportDialog()\" class=\"w-full py-2 mb-3 text-sm font-medium rounded-lg border transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed\" style=\"border-color: var(--border); color: var(--text-primary);\">Export all notes and highlights</button><p x-show=\"annotationsBannerLoading\" class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">Loading…</p><template x-if=\"!annotationsBannerLoading && bannerRows.length === 0\"><p class=\"text-sm py-4\" style=\"color: var(--text-secondary);\">No notes or highlights on this book yet.</p></template><!-- Notes first, then highlights — stable ledger order --><div class=\"space-y-2\"><template x-for=\"row in bannerRows\" :key=\"row.key\"><div class=\"card p-0 overflow-hidden relative\"><!-- Note indicator: a quiet stroke glyph in the card's\n\t\t\t\t\t\t upper-right corner (never left of text) — every\n\t\t\t\t\t\t note row, and highlights with an attached note. --><svg x-show=\"row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)\" class=\"absolute top-2 right-2 pointer-events-none reader-icon\" style=\"color: var(--text-secondary);\" width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg> <button type=\"button\" class=\"w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface-hover\" :class=\"(row.kind === 'note' || (row.kind === 'highlight' && row.hasNote)) ? 'pr-8' : ''\" @click=\"row.expanded = !row.expanded\"><span class=\"w-1.5 self-stretch rounded-full shrink-0\" :style=\"{ backgroundColor: row.kind === 'highlight' ? row.color : 'transparent' }\"></span> <span class=\"flex-1 min-w-0\"><span class=\"block text-sm\" x-text=\"row.preview\"></span> <span class=\"block text-xs mt-0.5\" style=\"color: var(--text-secondary);\" x-text=\"row.label\"></span></span></button><!-- Jump · edit (notes + attached highlight notes) · delete\n\t\t\t\t\t\t (location notes) — objective 8a --><div class=\"flex items-center justify-between px-4 pb-3 pt-1\"><a x-show=\"row.loc\" :href=\"'/readers/' + bannerMediaId + '?loc=' + encodeURIComponent(row.loc)\" class=\"text-xs px-2 py-1 rounded-lg border hover:bg-surface-hover transition-colors\" style=\"border-color: var(--border); color: var(--accent);\">Open reader here ↗</a><div class=\"flex items-center gap-1\"><button type=\"button\" @click=\"startBannerEdit(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" x-text=\"row.kind === 'highlight' ? (row.hasNote ? 'Edit note' : 'Add note') : 'Edit'\"></button> <button type=\"button\" @click=\"row.kind === 'note' ? deleteBannerNote(row) : deleteBannerHighlight(row)\" class=\"text-xs px-2 py-1 rounded-lg hover:bg-surface-hover\" style=\"color: var(--status-danger);\">✕ Delete</button></div></div><!-- Expanded rich body (markdown pipeline). Highlight\n\t\t\t\t\t\t rows split at the highlight→note boundary: the\n\t\t\t\t\t\t selection (bordered in the row color), a divider,\n\t\t\t\t\t\t then the note markdown — the break reads on its\n\t\t\t\t\t\t own, no label needed. --><div x-show=\"row.expanded\" class=\"px-4 pb-4\"><p x-show=\"row.kind === 'highlight'\" class=\"text-sm\" :style=\"row.kind === 'highlight' ? 'border-left: 3px solid ' + row.color + '; padding-left: .75rem; color: var(--text-secondary); margin: 0 0 ' + (row.hasNote ? '.75rem' : '0') + ';' : 'color: var(--text-secondary);'\" x-text=\"row.richSelection\"></p><div x-show=\"row.kind === 'highlight' && row.hasNote\" class=\"border-t mt-3 pt-3\" style=\"border-color: var(--border);\"></div><div class=\"note-rich text-sm\" x-html=\"row.richNote\"></div></div></div></template></div></div></div><!-- Note edit dialog (objective 8a): raw markdown source; for\n\t highlight rows the dialog edits the note ATTACHED to the\n\t highlight (never the highlight itself) --><div id=\"banner-note-dialog\" x-cloak x-show=\"bannerEditOpen\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background: rgba(0,0,0,.6);\"><div class=\"max-w-md w-full card p-6\"><h4 class=\"text-lg font-semibold mb-1\" style=\"color: var(--text-primary);\" x-text=\"bannerEditRow && bannerEditRow.kind === 'highlight' ? (bannerEditRow.hasNote ? 'Edit highlight note' : 'Add note to highlight') : 'Edit note'\"></h4><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary);\">Markdown supported</p><textarea x-model=\"bannerEditText\" rows=\"6\" class=\"input w-full text-sm\"></textarea><div class=\"flex gap-2 mt-4\"><button type=\"button\" @click=\"saveBannerNote()\" :disabled=\"!String(bannerEditText || '').trim()\" class=\"fLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+13 -13
View File
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -140,7 +140,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -179,7 +179,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -197,7 +197,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -215,7 +215,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -233,7 +233,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -251,7 +251,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -304,7 +304,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
+12 -4
View File
@@ -9,7 +9,7 @@ import (
"strings"
)
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -101,8 +101,15 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</button>
}
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4")
<span>Notes</span>
@Icon("trash", "h-4 w-4")
<span>Deleted</span>
if len(book.DeletedAnnotations) > 0 {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
}
</button>
<button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5">
@Icon("bookmark", "h-4 w-4")
<span>Annotations</span>
if book.NotesCount + book.HighlightsCount > 0 {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
}
@@ -433,7 +440,8 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
}
</div>
@ProgressSyncModal(user, book)
@NotesHighlightsModal(user, book)
@NotesHighlightsModal(user, book, tombstoneDays)
@AnnotationsBanner(book)
if user.Role == "admin" {
@MetadataEditorModal(book)
}
+10 -4
View File
@@ -128,7 +128,7 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
</div>
}
templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
templ NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays int) {
<div
id="notes-modal"
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
@@ -144,8 +144,8 @@ templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
@Icon("edit", "h-5 w-5")
</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Notes &amp; Highlights</h2>
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Deleted annotations</h2>
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title } — restore or purge</p>
</div>
</div>
<button
@@ -183,7 +183,13 @@ templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
</div>
<p class="text-xs mb-4" style="color: var(--text-secondary);">
Deleted entries are kept for the sync retention window before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
if tombstoneDays == 1 {
Deleted entries are kept for 1 day before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
} else if tombstoneDays > 1 {
Deleted entries are kept for { tombstoneDays } days before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
} else {
Deleted entries are kept until manually purged (retention disabled). Restore returns them to every synced device; Delete permanently removes them immediately.
}
</p>
<div class="space-y-3">
for _, ann := range book.DeletedAnnotations {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -97,7 +97,7 @@ func BookShelf(
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 93, Col: 135}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -110,7 +110,7 @@ func BookShelf(
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 95, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -173,7 +173,7 @@ func BookShelf(
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 147, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 147, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -191,7 +191,7 @@ func BookShelf(
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 149, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -212,7 +212,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -225,7 +225,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -238,7 +238,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -256,7 +256,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 156, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -269,7 +269,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 156, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -282,7 +282,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 156, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -301,7 +301,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -314,7 +314,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -327,7 +327,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -345,7 +345,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -358,7 +358,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -371,7 +371,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -400,7 +400,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 275, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 275, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -423,7 +423,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 284, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 284, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -454,7 +454,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 293, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -467,7 +467,7 @@ func BookShelf(
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 298, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 298, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
+9 -9
View File
@@ -41,7 +41,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 14, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 14, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -62,7 +62,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 22, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -83,7 +83,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 24, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -96,7 +96,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -109,7 +109,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -122,7 +122,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 33, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -135,7 +135,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 34, Col: 125}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -168,7 +168,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 44, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -181,7 +181,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 46, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
+7 -7
View File
@@ -72,7 +72,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 23, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -85,7 +85,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 26, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -98,7 +98,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 32, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -111,7 +111,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 45, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 58, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -137,7 +137,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 74, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -155,7 +155,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 116, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
+4 -4
View File
@@ -36,7 +36,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -65,7 +65,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 176}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -99,7 +99,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 45, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 45, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
+29 -29
View File
@@ -97,7 +97,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 71, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -110,7 +110,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 74, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -123,7 +123,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 77, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -136,7 +136,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 80, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -157,7 +157,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -178,7 +178,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 99, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -191,7 +191,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 100, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -246,7 +246,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -279,7 +279,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 132, Col: 166}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 166}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -292,7 +292,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 134, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -305,7 +305,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 135, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -355,7 +355,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -368,7 +368,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 112}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -381,7 +381,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 147}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -404,7 +404,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 194, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -417,7 +417,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 196, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -435,7 +435,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 201, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -453,7 +453,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 206, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -471,7 +471,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 209, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -551,7 +551,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 385, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -564,7 +564,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 386, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 386, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -577,7 +577,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 387, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
@@ -620,7 +620,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 397, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
@@ -638,7 +638,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
@@ -651,7 +651,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
@@ -669,7 +669,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 403, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 403, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil {
@@ -687,7 +687,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 406, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 406, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 411, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 411, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
@@ -713,7 +713,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 418, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 418, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
+9 -9
View File
@@ -54,7 +54,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 45, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -67,7 +67,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 53, Col: 97}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -128,7 +128,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 111, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -141,7 +141,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 117, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -162,7 +162,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -206,7 +206,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -65,7 +65,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
+1
View File
@@ -172,6 +172,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
<p class="text-xs mt-1 font-medium" id="dashboard-scope-label" style="color: var(--accent)"></p>
</div>
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
@Icon("close", "h-5 w-5")
+30 -30
View File
@@ -104,7 +104,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 40, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -117,7 +117,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 41, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -130,7 +130,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 45, Col: 130}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -143,7 +143,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 47, Col: 100}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -161,7 +161,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 49, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -184,7 +184,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 55, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -210,7 +210,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 68, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -231,7 +231,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 76, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -249,7 +249,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 81, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -289,7 +289,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 95, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -339,7 +339,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 108, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -357,7 +357,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 115, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -370,7 +370,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 116, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -388,7 +388,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 124, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -406,7 +406,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 130, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -419,7 +419,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 131, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -437,7 +437,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 134, Col: 100}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -450,7 +450,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 135, Col: 19}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -473,7 +473,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 143, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -486,7 +486,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 145, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -512,7 +512,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 152, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 152, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -525,7 +525,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 154, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
@@ -573,7 +573,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
templ_7745c5c3_Var26 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p><p class=\"text-xs mt-1 font-medium\" id=\"dashboard-scope-label\" style=\"color: var(--accent)\"></p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -593,7 +593,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 184, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
@@ -606,7 +606,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 185, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 186, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
@@ -627,7 +627,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 190, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 191, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -640,7 +640,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 192, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 193, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -668,7 +668,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 202, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 203, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
@@ -701,7 +701,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 228, Col: 128}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 229, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -714,7 +714,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 235, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 236, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more