Commit Graph
1641 Commits
Author SHA1 Message Date
john-okeefe d764f820b2 fix(conflicts): use winner's source name instead of 'manual'
When resolving a conflict, the last_sync_source is now set to the
winner's actual source name (koreader, web, etc.) rather than always
'manual'. This prevents subsequent saves from re-triggering conflicts.

Also removes the strict oneof validation on the winner field since
the source name is dynamic.
2026-06-02 19:45:07 -04:00
john-okeefe 39a2829cc8 feat(media): accept context_text from web reader progress saves
Web reader can now send surrounding text at current reading position.
Stored in reading_progress.context_text for use as CFI resolution
fallback when converting epubcfi to CREngine XPointer.
2026-06-02 19:45:00 -04:00
john-okeefe 795f10d2af feat(server): wire libraryService to KOReader handler
Required for CFI converter to resolve EPUB file paths during
bidirectional CFI conversion.
2026-06-02 19:44:53 -04:00
john-okeefe 44f38803dc feat(koreader): bidirectional CFI conversion in sync pipeline
Forward (push): When KOReader pushes a CREngine XPointer, convert it
to standard epubcfi before storing. Uses CFIConverter.ConvertCREToStandard
with context_text for text search fallback.

Reverse (pull): When KOReader pulls progress, convert stored standard
epubcfi back to CREngine XPointer via CFIConverter.ConvertStandardToCRE.
Returns as koreader_xpointer field in GetMetadata response.

Other changes:
- updateProgressForBook: pass context_text to SaveProgress
- enqueueProgressForBook: pass context_text to queue
- KOReaderProgressData: add KoreaderXPointer field
- New convertCFIToXPointer helper method
- Wire libraryService in main.go for EPUB path resolution
2026-06-02 19:44:47 -04:00
john-okeefe b2b1804aa3 feat(sync): plumb context_text through SaveProgress and sync queue
- SaveProgressRequest: add ContextText field
- SaveProgress: carry existing ContextText from DB, overwrite when provided
- ProgressUpdate: add ContextText field for checkpoint sync
- SyncQueueProcessor: serialize/deserialize context_text in sync data
- buildProgressSnapshot: include context_text in snapshot data
2026-06-02 19:44:38 -04:00
john-okeefe c82f20c3f2 feat(db): add context_text column to reading_progress
Stores surrounding text (~100 chars) at the reader's current position.
Used as fallback for CFI resolution when converting between epubcfi
and CREngine XPointer formats.

Updates:
- schema.sql: add context_text TEXT column, update stored procedure
- queries.sql: add context_text to GetUniversalProgress and
  UpdateUniversalProgress queries
- Regenerate sqlc Go code (models.go, querier.go, queries.sql.go)
2026-06-02 19:44:31 -04:00
john-okeefe 5a76fed099 feat(sync): add CFI converter for CREngine XPointer to standard epubcfi
Implements ConvertCREToStandard which converts CREngine XPointers
(e.g. /body/DocFragment[6]/body/div/p[47]/text().2399) to standard
epubcfi format (e.g. epubcfi(/6/12!/4/2[id]/4/1:7)).

Key components:
- indexChildNodes: faithful port of foliate-js's epubcfi.js algorithm
  for computing CFI-compatible child node indices including virtual
  positions, null positions between adjacent elements, and text chunks
- preprocessXHTML: converts XHTML self-closing tags (e.g. <a id="x"/>)
  to open/close pairs so Go's HTML parser produces the same DOM as the
  browser's XHTML parser
- buildCFI: walks up from a text node to body, computing CFI indices
  at each level using indexChildNodes
- findTextInNode: regex-based whitespace-flexible text search for
  context_text fallback positioning
- convertByPercentageOffset: estimates position via book-wide character
  counts when no context_text is available
- ConvertCREToStandard: orchestrates text search → percentage fallback

Supports CREngine XPointer format, CREngine fragment ID format
(#_doc_fragment_N_anchor), and includes round-trip test coverage for
1984 and Crime and Punishment EPUBs.
2026-06-02 19:44:05 -04:00
john-okeefe 342c2f88b2 chore(deps): update htmx.min.js to latest from npm
Re-copy htmx.min.js from node_modules/htmx.org as part of the build
process (npm run build:ts:dev). Minor size increase from 51238 to 51250
bytes.
2026-05-27 11:22:20 -04:00
john-okeefe 854a888306 fix(toast): show success toast for HTMX JSON responses
Previously, when HTMX form submissions (profile update, password change)
returned a successful JSON response like {"message": "profile updated
successfully"}, the raw JSON was swapped into the target div as plain text.

The htmx:afterSwap listener in toast.ts only handled error responses.
Extend it to also intercept successful 2xx JSON responses that contain a
"message" field, showing a green success toast and clearing the raw JSON
from the target element. Only JSON responses are intercepted (checked via
Content-Type header), so legitimate HTML swaps are unaffected.
2026-05-27 11:22:13 -04:00
john-okeefe a4b91393a6 chore: remove completed TIMEZONE_PLAN.md
The timezone feature has been fully implemented across the codebase
(database queries, API handlers, profile form, reader settings).
This planning document is no longer needed.
2026-05-27 11:22:04 -04:00
john-okeefe 0af2ee4951 fix(scanner): replace golang.org/x/text/cases.Title with manual titlecase to prevent panic on Unicode expansion
The cases.Title caser panicked with 'slice bounds out of range' when
processing certain Unicode characters that expand during case transformation
(e.g. ß → SS). This panic crashed the entire server during scanning, causing
WebSocket disconnections and failed scan requests.
2026-05-27 11:10:28 -04:00
john-okeefe d78a182f0d feat(header): add responsive mobile hamburger menu with slide-down panel
On viewports below 970px the header now collapses to a compact bar with
only the Bookhoard logo and a hamburger button. Clicking the hamburger
reveals a slide-down panel containing:

- Full-text search input (wired to the existing debounced search API)
- Navigation links (Library, All Books, Series, Collections, Progress, Devices)
- Collapsible theme switcher with all 7 themes and 4 bookshelf backgrounds
- User section: profile/admin/logout when logged in, inline login form when logged out

Changes:
- templates/header.templ: add hamburger button, mobile panel with all controls,
  hide desktop search/theme/user controls below nav breakpoint
- web/src/header.ts: add mobileMenuOpen state to Alpine header component
- web/src/search.ts: refactor initializeSearch to wire both desktop and mobile
  search inputs, track active input for results container placement
- tailwind.config.ts: add custom 'nav' screen breakpoint at 970px so the
  mobile menu activates before the search bar becomes unusable
- web/static/style.css: rebuilt with new nav breakpoint utility classes
2026-05-24 21:20:36 -04:00
john-okeefe 13260d8c1f chore(templates): regenerate all _templ.go files with templ v0.3.1020
Upgrade from templ v0.3.1001 to v0.3.1020. Generated code changes include
JoinStringErrs -> ResolveAttributeValue and removal of manual EscapeString
calls (now handled internally by ResolveAttributeValue).
2026-05-24 21:20:24 -04:00
john-okeefe 6fcd7f4400 fix(docker): add Go build cache mount and remove unnecessary -a flag
Add --mount=type=cache for Go build cache to speed up repeated builds.
Remove the -a flag which forces full rebuilding of all packages and is
unnecessary when using cache mounts.
2026-05-24 21:20:12 -04:00
john-okeefe 6e8986bea5 fix(docker): use npm install instead of npm ci for self-contained builds
npm ci requires a package-lock.json and installs exactly what it specifies.
npm install resolves from package.json directly, making the Docker build
fully self-contained — you can clone the repo and run docker build with no
local Node tooling or pre-existing lockfile.
2026-05-24 20:23:57 -04:00
john-okeefe 87da5c3cf5 fix(reader): fix PDF rendering broken by Vite bundling of foliate-js
PDFs failed to open with error:
  Invalid factory url: "http://localhost:8765/static/undefined"

Root cause: foliate-js's pdfjsPath() uses new URL(dynamicPath, import.meta.url)
to resolve runtime asset paths (standard_fonts/, cmaps/). Vite transforms this
pattern into a static asset map lookup at build time, but can only resolve
known static file paths — not dynamically-constructed directory paths. The
lookup returns undefined, producing a broken URL.

Fix (two parts):

1. foliate-js fork (commit d164d6f): Export an overridable config.pdfjsPath
   function. Module-level code (worker, CSS) continues using import.meta.url
   directly (works fine with Vite for static filenames). The makePDF function
   uses config.pdfjsPath for runtime paths, allowing consumers to override it.

2. Bookhoard changes:
   - Update foliate-js dependency to d164d6f
   - Override config.pdfjsPath in reader.ts to resolve to /static/vendor/pdfjs/
   - Add Vite plugin (pdfjsAssets) that copies standard_fonts/ and cmaps/ from
     node_modules to the build output during vite build (the standard approach
     used by react-pdf and other pdfjs-dist consumers)
   - Remove manual cp commands from build:ts scripts
2026-05-24 20:23:41 -04:00
john-okeefe fb9427a864 refactor(makefile): auto-detect container runtime, remove systemd workarounds
Replace hardcoded 'podman' with auto-detected CONTAINER_RUNTIME variable
that prefers docker and falls back to podman. Override with:
  CONTAINER_RUNTIME=podman make rebuild-app

Remove the ensure_healthy and compose_up macros that worked around
podman-compose hanging on non-systemd systems (e.g., Void Linux with
runt). These are no longer needed — healthchecks are now handled by
plain wait loops in the targets themselves, and compose up -d no longer
blocks on health conditions.
2026-05-24 20:23:23 -04:00
john-okeefe 0af940c7b3 chore(database): regenerate with sqlc v1.31.1
Regenerated database code after sqlc version upgrade from v1.30.0 to
v1.31.1. No functional changes — only the version header in generated
files was updated.

Files: db.go, models.go, querier.go, queries.sql.go
2026-05-24 20:23:08 -04:00
john-okeefe f8745da6b2 chore(deps): update htmx.min.js 2026-05-23 23:48:13 -04:00
john-okeefe 604a2458e9 fix(makefile): add non-systemd podman healthcheck workaround
Podman relies on systemd timers to schedule automatic healthchecks. On
non-systemd systems (e.g., Void Linux with runit), healthchecks never
fire, which causes podman-compose to hang forever waiting for
service_healthy conditions that never resolve.

Add two Make macros to handle this transparently:

- compose_up: runs podman compose up -d normally on systemd, but with
  a 15-second timeout on non-systemd to create containers without
  hanging. Supports passing compose flags via $(call compose_up,args).

- ensure_healthy: on non-systemd systems, waits for the database to
  accept connections, manually triggers its healthcheck, starts the app
  container, waits for the app health endpoint, and triggers its
  healthcheck. On systemd systems, the runtime check is skipped entirely
  (zero overhead).

Both macros use a runtime shell check for /run/systemd/system, so the
same Makefile works identically on all systems without parse-time
conditionals.

Applied to all compose-up targets: up, rebuild, rebuild-force,
rebuild-force-db, rebuild-app, rebuild-app-force, test-integration,
and test-env-up.

Refs: https://github.com/containers/podman/pull/27033
2026-05-23 23:48:06 -04:00
john-okeefe 666b72c4fd feat(router): cookie-aware SSR library resolution with resolveLibrary helper
- helpers.go: Promote getText() from a local closure in frontend.go
  to a package-level function so it can be used by resolveLibrary.
  Add resolveLibrary(c, cfg, user.ID) helper that:
    1. Reads library_id query param (explicit navigation wins)
    2. Falls back to selectedLibrary cookie — validates __all__
       sentinel or real UUID, rejects garbage values silently
    3. Falls back to user's first visible library
  Returns LibraryResolution struct with LibraryID, IsAll, LibUUID,
  Libraries, and FirstID — eliminating repeated boilerplate across
  all SSR routes.

- frontend.go: Replace manual library resolution boilerplate in 5
  SSR route handlers (series, tags/detail, bookshelf, dashboard,
  collections/:id) with resolveLibrary(). Each route now gets cookie-
  aware library selection for free. Collection detail correctly
  handles All Libraries mode for both system and user collections.
  Dashboard no longer makes a redundant second GetUserVisibleLibraries
  call.
2026-05-18 17:53:42 -04:00
john-okeefe 7221906d53 feat(ts): centralized library storage with cookie-based SSR support
- storage.ts: Centralize ALL_LIBRARIES = "__all__" sentinel constant.
  setSelectedLibrary() now writes both localStorage and a cookie
  (selectedLibrary, Path=/, SameSite=Lax, max-age=365d). The
  sentinel "__all__" is used in both storage mediums — empty strings
  are never stored. getSelectedLibrary() maps __all__ back to "".
  Cookie enables server-side rendering to read the stored library
  selection without access to localStorage.

- library-switcher.ts: Import ALL_LIBRARIES and setSelectedLibrary/
  getSelectedLibrary from storage.ts instead of managing localStorage
  directly. Remove local constants.

- dashboard.ts: Remove duplicate localStorage.setItem call that was
  overwriting the __all__ sentinel with raw empty string. Fix
  reloadPage() and scan-complete handler to work with empty libraryId.
  openDashboardSettings/saveDashboardSettings show clear messages for
  All Libraries mode.

- collections.ts: Remove library switcher initialization from the
  collections list page — the list page no longer has a switcher.

- series.ts: Rewrite to use initLibrarySwitcher from library-switcher
  module and switchWithTransition for navigation. Series card links
  no longer include library_id in their URLs.

- bookshelf.ts: Autocomplete fetch calls handle empty libraryId
  correctly for All Libraries mode.

- search.ts, collection-rules.ts: Use setSelectedLibrary() and
  getSelectedLibrary() from storage.ts instead of direct localStorage
  access.
2026-05-18 17:53:27 -04:00
john-okeefe 0a9cde0fc8 chore(templates): regenerate all _templ.go files
Regenerate all templ-generated Go files. These changes are caused
by running templ generate with a slightly different CLI version
(v0.3.1001) than the go.mod dependency (v0.3.1020), resulting in
minor formatting/import diffs across all templates. No functional
changes.
2026-05-18 17:53:12 -04:00
john-okeefe d3a510d2e0 fix(templates): library switcher and bookshelf filter improvements
- bookshelf.templ: Fix form field name from "library" to "library_id"
  to match the handler's QueryParam("library_id"). Add "All Books"
  as the default option in the library filter dropdown. The bookshelf
  uses its own inline filter, NOT the universal library switcher.

- collections.templ: Remove @LibrarySwitcher from the collections list
  page — collections are not library-specific, so the switcher was
  misleading. Fix data-id interpolation bug where {collection.ID} was
  rendered as literal text instead of being interpolated.

- series.templ: Replace inline library selector with the universal
  @LibrarySwitcher component. SeriesCard links no longer include
  library_id since series detail always shows all books.
2026-05-18 17:52:49 -04:00
john-okeefe a6f5d8d693 refactor(handlers): relax library_id validation for All Libraries
- dashboard.go: library_id query param is now optional. Empty/missing
  library_id is passed as pgtype.UUID{Valid: false} to the service
  layer, enabling All Libraries mode.

- series.go: library_id is optional for series listing. GetSeriesBooks
  no longer receives a libraryID — it always returns all books in a
  series regardless of library.

- collections.go: Restructure GetCollection to handle system
  collections (query_type != "") with an optional libraryID. When
  libraryID is empty (All Libraries), GetDashboardSections receives
  pgtype.UUID{Valid: false} so no library filter is applied.
2026-05-18 17:52:37 -04:00
john-okeefe 6a352a6afb refactor(services): accept optional libraryID for All Libraries support
- dashboard_service.go: Change libraryID parameter from uuid.UUID to
  pgtype.UUID across GetDashboardSections, GetDashboardPreferences,
  and all helper methods. pgtype.UUID{Valid: false} now signals
  "no library filter" (All Libraries), which gets passed through
  to sqlc.narg() in the SQL layer.

- series_service.go: Drop libraryID parameter from GetSeriesBooks
  entirely. Series are not library-specific — all books in a series
  are shown regardless of which library they belong to.
2026-05-18 17:52:24 -04:00
john-okeefe e7a4f0f758 refactor(sql): use sqlc.narg() pattern for optional library_id in all library-filtered queries
Convert 12 SQL queries to use sqlc.narg('library_id') instead of
direct @library_id parameters. This allows passing a NULL/invalid
pgtype.UUID to mean "no library filter" (i.e., All Libraries),
making the SQL layer correctly handle the optional filter via:
  (sqlc.narg('library_id')::uuid IS NULL
   OR mi.library_id = sqlc.narg('library_id')::uuid)

Also remove the library_id filter from GetSeriesBooks entirely —
a series is a series regardless of library.

Queries affected:
- GetDashboardSections, GetRecentlyAdded, GetInProgress
- GetHighestRated, GetMostRead, GetAbandonedBooks
- GetLeastRead, GetBooksByTag, GetCollectionItemsForDashboard
- SearchMediaItemsUnified, GetSeriesCardsData

Generated code (queries.sql.go, querier.go) regenerated via sqlc.
2026-05-18 17:52:13 -04:00
john-okeefe 64d3e8d272 feat(frontend): integrate shared library switcher into all pages
Wire up the shared library switcher module on dashboard, collections
list, collection detail, and collection rules pages. All pages use SSR
for initial load and AJAX with fade transitions on library switch.

web/src/collections.ts:
- Add initCollectionsPage() that auto-detects list vs detail page
  by checking for #collection-data element
- Collections list: onSwitch fetches /api/collections?library_id=X and
  re-renders the grid with per-library book counts
- Collection detail: onSwitch fetches /api/collections/:id?library_id=X
  and re-renders the books grid
- Add renderCollectionsGrid() and renderCollectionBooks() with
  Alpine.initTree() calls for dynamic content
- Collection cards now link with ?library_id= from selected library
- Update hidden #collection-data data-library-id on switch

web/src/dashboard.ts:
- Replace standalone switchLibrary() with initLibrarySwitcher() +
  switchWithTransition() from shared module
- Extract fetchAndRenderSections() helper shared by onSwitch callback,
  reloadPage(), and saveDashboardSettings()
- Remove inline #library-select change listener and switch-library
  data-action handler (now handled by shared module)
- Scan-complete event handler unchanged (independent incremental logic)

web/src/collection-rules.ts:
- Update backToCollection() to preserve library context by appending
  ?library_id= from localStorage selectedLibrary key
2026-05-17 21:12:45 -04:00
john-okeefe 8e48aa4334 feat(templates): add library switcher to collections and dashboard pages
Replace inline library switcher HTML with shared LibrarySwitcher component
across all collection pages and the dashboard.

templates/collections.templ (Collection):
- Update signature to accept libData []LibraryData, currentLibraryID
- Add @LibrarySwitcher(libData, currentLibraryID) after header
- Change x-init to initCollectionsPage() for unified initialization

templates/collections.templ (CollectionDetail):
- Update signature to accept libData []LibraryData
- Add @LibrarySwitcher(libData, libraryID) after header
- Fix broken "Back to Collections" button: replace non-existent
  backToCollections Alpine method with a plain <a href="/collections"> link
- Change x-init to initCollectionsPage() for unified initialization

templates/dashboard.templ:
- Replace 46-line inline sticky library selector (lines 20-66) with
  @LibrarySwitcher(libData, currentLibraryID, DashboardActions())
- Dashboard-specific settings and refresh buttons extracted into the
  DashboardActions sub-component via the variadic actions parameter
2026-05-17 21:12:26 -04:00
john-okeefe 2fa3764df5 feat(ssr): pass library data to collection page templates
Update frontend route handlers for /collections and /collections/:id
to fetch user-visible libraries and pass libData + currentLibraryID
to templates, enabling the library switcher dropdown.

/collections handler:
- Fetch GetUserVisibleLibraries for the current user
- Derive currentLibraryID from query param, falling back to first library
- Convert to []templates.LibraryData and pass to Collection template

/collections/:id handler:
- Fetch GetUserVisibleLibraries alongside existing book fetching
- Pass libData to CollectionDetail template alongside existing libraryID
- Refactored to use shared libraryID variable across system/user paths
2026-05-17 21:12:13 -04:00
john-okeefe faef4d9fff feat(api): add library_id filtering to collections endpoints
Add optional library_id query parameter support to GetCollections and
GetCollection API handlers for library-scoped book filtering.

GetCollections (GET /api/collections?library_id=X):
- When library_id is provided, include per-library book_count in the
  response by querying GetCollectionItemsForDashboard for each collection
- When omitted, returns all collections as before (backward compatible)
- Added BookCount field to CollectionResponse struct

GetCollection (GET /api/collections/:id?library_id=X):
- System collections (non-empty QueryType): uses DashboardService to
  fetch library-scoped sections, matching the existing SSR handler logic
- User collections: uses GetCollectionItemsForDashboard for
  library-filtered results, excluding soft-deleted items
- When library_id is omitted, returns all books as before
2026-05-17 21:12:01 -04:00
john-okeefe a2fc2613b4 feat(library-switcher): add shared library switcher component and module
Add reusable library switcher infrastructure that can be used across
dashboard, collections list, and collection detail pages.

New files:
- templates/library_switcher.templ: Shared LibrarySwitcher component
  with variadic actions slot for page-specific buttons (e.g. dashboard
  settings/refresh). Includes DashboardActions sub-component.
- web/src/library-switcher.ts: Shared module providing:
  - initLibrarySwitcher(): syncs dropdown with localStorage, attaches
    change listener with configurable onSwitch callback
  - switchWithTransition(): generic fade-out -> spinner -> fetch ->
    fade-in transition used by all pages
  - getCurrentLibraryId(): reads "selectedLibrary" from localStorage

Modified:
- web/src/main.ts: import new library-switcher module
- web/src/types/api.d.ts: add book_count field to CollectionData
2026-05-17 21:11:47 -04:00
john-okeefe f23ee6e66a chore(templates): regenerate all templ Go files, add scan spinner to header
Regenerated templ output for all template files. Key source change:
- templates/header.templ: add scan progress spinner SVG and percentage
  display to header nav, initialize scan listener via x-init
2026-05-16 19:31:52 -04:00
john-okeefe 73fc609d7b fix(tests): protect dev admin from test cleanup, use isolated test names
Tests were deleting the development admin user, causing ON DELETE SET NULL
to cascade and set created_by_admin_id to NULL on all libraries.

- test_helpers: skip deletion of testuser@tests.bookhoard.internal
- sync_integration_test: use test-sync% prefix for isolated test data
2026-05-16 19:31:46 -04:00
john-okeefe 5dca78789d fix(docker): exclude uploads/ from build context
The uploads/ directory is bind-mounted at runtime via docker-compose and
should not be copied into the Docker build context. This was slowing down
builds and including potentially large media files in the context.
2026-05-16 19:31:39 -04:00
john-okeefe 37e092c2a8 feat(dashboard): dynamic scan-complete refresh without page reload
When a scan completes, dynamically update the dashboard carousels instead of
requiring a full page reload:

- Listen for bookhoard:scan-complete custom event dispatched by header
- Fetch updated sections from /api/dashboard/sections
- Diff existing book cards by data-media-item-id attribute
- Prepend new items to carousel tracks (afterbegin) to match API sort order
- Create entirely new section DOM for sections that don't yet exist on page
- Remove 'No items' placeholder when items are added
- Scroll carousel to left (scrollLeft=0) so newly prepended items are visible

Also:
- Extract renderSectionHTML() helper from renderDashboardCollections() for reuse
- Add data-media-item-id attribute to book card template for DOM diffing
- Add diagnostic console.log statements for debugging scan-complete flow
2026-05-16 19:31:34 -04:00
john-okeefe 4f7794767d feat(header): add scan progress spinner and dispatch scan-complete event
Add a scan progress indicator to the header that shows during library scans:
- Spinning SVG icon next to the BookHoard title
- Percentage display during active scans
- Dispatches bookhoard:scan-complete custom DOM event on window when scan
  finishes, enabling other components (dashboard) to react without polling
- Auto-resets progress display after 3 seconds
- Uses WebSocket pub/sub via addListener/removeListener with cleanup on
  header element removal
2026-05-16 19:31:23 -04:00
john-okeefe d61abb1be1 refactor(websocket): convert to pub/sub pattern with addListener/removeListener
Replace the single-listener createWebSocket pattern with a pub/sub model
using addListener/removeListener. This allows multiple components (header
spinner, dashboard refresh) to subscribe to WebSocket messages independently
without clobbering each other's handlers.

- Maintain a Set of message listeners
- Auto-connect on first addListener, auto-disconnect when last listener removed
- Retain reconnect logic with configurable delay
2026-05-16 19:31:16 -04:00
john-okeefe 1afc202ba9 chore(server): call SyncAllowedExtensions on startup 2026-05-16 19:31:09 -04:00
john-okeefe 5caebcfe45 feat(worker): broadcast scan_complete WebSocket message on scan job finish
The MessageTypeScanComplete constant existed but was never actually sent by
the worker. This meant the frontend had no way to know when a scan finished.

- After a JobTypeScan completes, broadcast scan_complete to the job's user
  via WebSocket ConnectionManager
- Includes job_id, files_scanned, new_items, and errors in the payload
- Only broadcasts for JobTypeScan (not other job types) when connManager
  is available and job.UserID is set
2026-05-16 19:31:02 -04:00
john-okeefe b1e85dca79 fix(auth): hand over library and media item ownership on admin deletion
When an admin was deleted, the ON DELETE SET NULL foreign key would set
created_by_admin_id to NULL on all their libraries. This caused the scanner
to fail to find an admin ID for broadcasting scan-complete WebSocket messages.

- On admin deletion, reassign all libraries and media items to the next admin
- Prevents created_by_admin_id from ever being NULL on active libraries
- Uses new ReassignLibraries and ReassignMediaItems DB queries
2026-05-16 19:30:54 -04:00
john-okeefe b8dc4e87d5 fix(library): sync allowed extensions from Go source of truth to DB on startup
AllowedExtensions in Go was the intended single source of truth for library
type file extensions, but it was never synced to the database. This caused
missing extensions like .pdf for manga to be absent from library_types.

- Add SyncAllowedExtensions() to sync Go AllowedExtensions map to DB
- Call SyncAllowedExtensions() from cmd/server/main.go on startup
- Ensure .pdf is included in manga extensions
2026-05-16 19:30:46 -04:00
john-okeefe 0855dd7b3b fix(scanner): replace mtime polling with recursive fsnotify watching
The root cause of scanner failures in Podman containers was NOT that
inotify doesn't work through bind mounts (it does — same kernel, same
inodes). The real bug was SetFolders() only watching root directories.
Linux has no recursive inotify — every subdirectory must be added
individually to the watcher.

Changes:
- SetFolders() now walks all subdirectories and adds each to the watcher
  (same approach as Audiobookshelf/Kavita)
- Remove broken mtime-based detection: seedDirectoryMtimes,
  pollDirectoryChanges, detectChangedRoots, checkDirectoryMtimes,
  SyncFilesystemWithDatabase — all unreliable in container overlay mounts
- Replace StartPolling with startBackupScan: enqueues full JobTypeScan
  every 5 minutes (down from 30) as a safety-net fallback
- enqueueLibraryScan() sets job.UserID from admin ID so the worker can
  broadcast WebSocket messages
- performInitialScan() sets job.UserID for the same reason
- Add [WATCHER] prefix logging to all fsnotify event loop messages
- Add defense-in-depth: fallback to GetFirstAdmin() when library has
  no created_by_admin_id (NULL from test cleanup)
- Fix processDirectoryScanJob to use prefix-match (GetLibraryByFolderPathPrefix)
- Fix nil context panic: all jobs now set Context: context.Background()
- Remove mtime-related tests; update default interval test from 30m to 5m
2026-05-16 19:30:39 -04:00
john-okeefe d0460885ff feat(db): add imported_at column to media_items for accurate "Recently Added" sorting
The created_at column stores file modification time (intentional for preserving
original metadata), but this makes 'Recently Added' sorting unreliable for
imported files. Add imported_at column that records the actual database insert
timestamp.

Changes:
- Add imported_at TIMESTAMPTZ column to media_items (nullable)
- Update GetRecentlyAddedItems to sort by imported_at DESC NULLS LAST first
- Add ReassignLibraries and ReassignMediaItems queries for admin deletion handover
- Add SyncLibraryTypeExtensions query for startup extension sync
- Update all media_items SELECT queries to include imported_at column
2026-05-16 19:30:27 -04:00
john-okeefe ced90cd1f4 feat(scanner): add directory mtime-based fast polling for container environments
Podman rootless containers with overlay storage do not propagate inotify
events through bind mounts, making the fsnotify file watcher ineffective.
This caused new files added on the host to go undetected until the
5-minute full-filesystem-walk polling fallback caught them.

Add a lightweight directory mtime polling mechanism that runs every 10
seconds, checking stat() on all subdirectories under watched library
folders against a cached mtime value. When a directory's mtime changes
(indicating files were added/removed/renamed), it feeds into the existing
markDirectoryDirty() → processDirtyDirectories() → job queue pipeline.

Changes:
- Add dirMtimes cache + mutex to MediaScanner struct
- Add seedDirectoryMtimes() to populate cache on startup (prevents
  false-positive flood on first poll)
- Add pollDirectoryChanges() goroutine (10s ticker) and
  checkDirectoryMtimes() (walks directories, compares mtimes)
- Launch mtime poller from WatchChanges() alongside existing goroutines
- Rename StartPolling logs to [ORPHAN-CLEANUP] to clarify its role
- Change default poll interval from 60s → 30m (new file detection now
  handled by the fast mtime poll; full sync focuses on orphan cleanup)
- Update GetScanSettings default from 60 → 1800 seconds
- Add 5 tests: seed cache, skip nonexistent, detect new dir, skip
  unchanged, detect modified dir

Expected result: new files detected in ~20 seconds (10s poll + 10s
debounce) regardless of inotify/container support.
2026-05-12 16:54:35 -04:00
john-okeefe 4b43bd04ba chore: regenerate templ files for v0.3.1001
Reverts generated Go template files from templ v0.3.1020 back to
v0.3.1001 output. Changes include filename path prefix adjustments
(admin_library.templ → templates/admin_library.templ) and attribute
handling differences (ResolveAttributeValue → JoinStringErrs + EscapeString).
2026-05-12 16:54:14 -04:00
john-okeefe 553a1dc19b refactor(reader): remove vendored pdfjs files from git, drop CJK cmaps
Remove 185 binary files (169 CMaps + 16 standard fonts) from git
tracking. These are build artifacts copied from
node_modules/@bookhoard/foliate-js at build time and should not be
version-controlled.

Changes:
- Remove web/static/vendor/pdfjs/ from git (169 cmap files + 16
  standard font files)
- Add web/static/vendor/ to .gitignore
- Drop CJK cmap copying from build scripts — the app is English-only
  and CJK support can be re-added later if needed (saves ~1.7MB in
  the container image)
- Update all three build scripts (build:ts, build:ts:dev,
  build:ts:watch) to copy only standard_fonts/ from node_modules
- Remove cMapUrl from reader.ts PDF config since we no longer ship
  cmaps
- Keep standardFontDataUrl pointing to the build-copied fonts which
  are needed for PDFs with non-embedded standard fonts (Helvetica,
  Times, Courier, etc.)
2026-05-11 14:57:22 -04:00
john-okeefe f3cacd1b16 fix(docker): relax healthcheck intervals and add start_period to postgres
The postgres container was being healthchecked every 5s which is
aggressive for a database, especially on slower machines or under load.
The bookhoard service was checked every 10s.

- Increase postgres healthcheck interval from 5s to 30s
- Add start_period: 10s to postgres to give it time to initialize
  before healthcheck failures count against retries
- Increase bookhoard healthcheck interval from 10s to 30s
2026-05-11 14:56:53 -04:00
john-okeefe b829b7fe22 chore: regenerate all templ Go files for v0.3.1020
templ v0.3.1020 generates different code than v0.3.1001 (uses
ResolveAttributeValue for attribute handling). Regenerated all 33
_templ.go files to match the new runtime.
2026-05-10 16:13:53 -04:00
john-okeefe d42d09b8db chore: upgrade templ v0.3.1001 → v0.3.1020, pin in Dockerfile
Newer templ generates ResolveAttributeValue calls that don't exist in
v0.3.1001 runtime, causing Docker build failures ("undefined:
templ.ResolveAttributeValue"). Pin templ CLI version in Dockerfile to
match go.mod instead of using @latest.

Also updated local templ CLI to v0.3.1020 to match.
2026-05-10 16:13:36 -04:00