Commit Graph
468 Commits
Author SHA1 Message Date
john-okeefe b1d2ccc87c feat(opds): serve comic archives in native format with correct mime types
The OPDS device catalog previously hard-coded every acquisition link as
application/epub+zip and always offered kepub/pdf alternate links, which
is wrong for comic archives (cbz/cbr/cb7/cbt) and other non-epub media.

- Resolve the acquisition mime type from the media item's stored
  mime_type (falling back to format_mimetype, then epub) instead of
  assuming epub
- Only offer reflowable conversions (kepub for kobo, pdf) for ebooks;
  comic archives are served as-is in their native format
- Derive the native format label (epub/pdf/cbz/cbr/...) from the file
  path in ListFormats rather than always reporting epub
- Add resolveMimeType, isComicArchive, and formatLabelFromPath helpers
2026-06-07 00:03:18 -04:00
john-okeefe f37de6c07c feat(router): add setup redirect middleware and setup routes
Add setupRedirectMiddleware that checks the setup_complete system
setting on every request. If setup is incomplete, all non-setup
requests are redirected to /setup so the wizard is the first thing
new users see. The check uses an in-memory cache (10s TTL) to avoid
hitting the database on every request, with cache invalidation on
setup completion.

The middleware skips /setup, /api/*, /static/*, /health, and
/favicon.ico so the wizard page, API calls, and static assets load
normally during setup.

Register two new routes:
- GET /setup: renders the setup wizard SSR template
- PUT /api/setup/complete: marks setup as complete (JWT-protected,
  requires an authenticated admin user created in step 1)
2026-06-06 00:04:00 -04:00
john-okeefe cea8e64da2 feat(api): add setup_complete system setting and status handlers
Add 'setup_complete' boolean to the system_settings seed data (defaults
to false) so fresh databases start in the unconfigured state.

Add two new handlers to SystemSettingsHandler:
- SetSetupComplete: marks setup_complete=true in the database
- GetSetupStatus: reads the current setup_complete value, returns
  {setup_complete: bool} JSON response, defaults to false if the
  setting row is missing or unparseable
2026-06-06 00:03:52 -04:00
john-okeefe 307a43f6b0 test(sync): add cross-element matching tests, enable <em> KEPUB conversion test
cfi_converter_test.go:
- TestFindTextInNode_SingleTextNode: baseline single-node match
- TestFindTextInNode_CrossEmElement: 'Vokalia and Consonantia' across
  two <em> elements — verifies match returns the 'Vokalia' text node
- TestFindTextInNode_CrossStrongElement: text crossing <strong> boundary
- TestFindTextInNode_DoesNotCrossParagraphs: verifies block boundary
  enforcement — text split across <p> elements is NOT matched
- TestFindTextInNode_NestedFormatting: <em><strong> nesting
- TestFindBlockParent: verifies findBlockParent walks up through inline
  elements to find block-level <p>
- TestCollectInlineText: verifies text segments are collected in order
  with correct content

kepub_cfi_converter_test.go:
- TestKEPUBConvertWithEmElements: previously skipped, now expects exact
  precision and verifies round-trip conversion works for text spanning
  <em> element boundaries
2026-06-03 20:00:54 -04:00
john-okeefe d5018936a0 refactor(sync): rewrite extractSurroundingText to use block-parent text collection
Instead of reading only from the single resolved text node (which
produces a tiny window when the position is inside <em> or other inline
elements), extractSurroundingText now:

1. Finds the block-level parent (e.g. <p>) of the resolved text node
2. Collects all text within that block, transparently crossing inline
   formatting elements via collectInlineText
3. Computes the global offset of the original text node within the
   concatenated block text
4. Extracts the [offset-window : offset+window] slice

This gives a full context window regardless of inline element
boundaries, enabling accurate text bridging between EPUB and KEPUB
documents even when reading positions fall inside <em>, <strong>,
<span class="koboSpan">, etc.

Falls back to single-node extraction when no block parent is found
(e.g. orphan text nodes in tests).
2026-06-03 20:00:40 -04:00
john-okeefe 3a54dda5c5 feat(sync): transparently cross inline formatting elements in text search
When finding or extracting text in EPUB/KEPUB DOM trees, inline
formatting elements like <em>, <strong>, <i>, <b>, <span>, etc. should
not break text continuity. A reader sees 'Vokalia and Consonantia' as
one phrase regardless of the <em> wrappers around each word.

Add inline formatting element set and helper functions:
- isInlineFormatting: checks if an element is an inline phrasing element
- collectInlineText: flattens text across formatting elements within
  a block-level parent, returning segments that map back to original
  text nodes
- findBlockParent: walks up from a text node to find the nearest
  block-level ancestor (used to scope text collection)
- findTextAcrossInlineElements: fallback for findTextInNode that
  concatenates text within each block element (transparently crossing
  formatting elements) and maps match positions back to actual nodes
- collectBlockElements: gathers all block-level elements containing text

The key invariant: text collection NEVER crosses block-level element
boundaries (<p>, <div>, <h1>-<h6>, <li>, etc.) to avoid concatenating
text from different paragraphs.

The findTextInNode function now tries single-text-node matching first
(fast path, unchanged), then falls back to cross-element matching only
when needed. This preserves performance for the common case.
2026-06-03 20:00:26 -04:00
john-okeefe 3c2a504747 test(sync): add comprehensive tests for KEPUB CFI converter
Test coverage for KEPUBCFIConverter with programmatically generated
EPUB and KEPUB zip fixtures:

- KEPUB->Standard text search conversion (exact precision)
- Standard->KEPUB text search conversion with round-trip verification
- Multi-position round-trip (3 phrases across different paragraphs)
- Cross-chapter conversion (spine index 1)
- No-context-text conversion (extracts surrounding text from resolved node)
- Invalid CFI handling (falls back to percentage)
- Percentage fallback for unresolvable CFIs
- 5-paragraph round-trip covering different document positions
- CFI structural difference verification (koboSpan adds DOM steps)
- Spine index consistency between EPUB and KEPUB
- Real book conversion test (skips if file not present)
- extractSurroundingText unit tests
2026-06-02 21:21:56 -04:00
john-okeefe b0dc0c591e feat(sync): add KEPUB <-> standard EPUBCFI bidirectional converter
Add KEPUBCFIConverter in internal/sync that converts between KEPUB CFIs
(which include extra koboSpan DOM steps) and standard EPUB CFIs at sync
time, so only standard epubcfi values are stored in the database.

The converter works by:
1. Resolving the source CFI in the source document (EPUB or KEPUB)
2. Extracting surrounding text at the resolved position
3. Searching for that same text in the target document
4. Building a new CFI pointing to the matched text in the target

This text-content bridging handles the DOM structural differences
between EPUB (text nodes at depth 2) and KEPUB (text nodes wrapped in
<span class="koboSpan"> at depth 3).

Falls back to percentage-based positioning when text search fails,
matching the pattern used by the existing CRE converter.

No changes to existing cfi_converter.go or KOReader conversion code.
Same-package access to unexported functions (resolveCFIToNode, buildCFI,
findTextInNode, etc.) via internal/sync package placement.
2026-06-02 21:21:28 -04:00
john-okeefe 1f8c3c21ae fix(admin): cascade base_url to opds/api URLs, fix pending registration time format
- UpdateSystemConfiguration: when base_url changes, automatically
  update opds_base_url and api_base_url derived configs
- convertPending: format time.Time as RFC3339 string instead of
  relying on string type assertion which would panic
2026-06-02 19:45:39 -04:00
john-okeefe 5d22021e8e fix(opds): epubcfi Atom compatibility, path deduplication, auth tokens
- Add <title> and <author><name> elements to Atom feed for compatibility
- Remove doubled /opds/opds/ path prefix in feed URLs
- Include ?token= auth param on all OPDS URLs
- Serve kepub links only for Kobo devices
- Fix URL construction for entries and acquisitions
2026-06-02 19:45:21 -04:00
john-okeefe 8e4412544d fix(devices): move device creation from CheckRegistration to ApproveDevice
Previously device creation happened in CheckRegistrationStatus (polling
endpoint), which was racy. Now the admin's ApproveDevice handler
creates the device record and stores auth token + device ID on the
registration entry. CheckRegistrationStatus just returns the pre-created
credentials.

Also adds approved/authToken/deviceID/syncEndpoints fields to
PendingRegistration struct.
2026-06-02 19:45:14 -04:00
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 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 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 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 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 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 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 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 dc07a2f19f fix(search): add json tags to FieldValue struct for correct API response
FieldValue struct had no json tags, so Go marshaled fields as uppercase
(Value, Count, Score) but frontend expected lowercase (value, count).
This caused all autocomplete dropdowns (tags, author, series, language)
to silently fail — tagSuggestions[].value was undefined, crashing
toLowerCase() calls and producing empty dropdowns.
2026-05-10 16:12:10 -04:00
john-okeefe fa38ca0d1b feat(db): add GetBooksByTag query for tag detail page
Uses $2 = ANY(tags) to match against the tags text[] column with GIN
index support. sqlc generates a single string Column2 param (not []string).
2026-05-10 16:11:46 -04:00
john-okeefe ae2e03d499 refactor(templates): generalize SeriesDetail into reusable BrowseDetail
Replace the single-purpose SeriesDetail template with a parameterized
BrowseDetail component that accepts badge icon/label, title, page title,
back URL/label, empty state icon/message, and book list. Both series
detail and new tag detail pages use the same template with different
params, eliminating duplication.

Series detail: 📚 Series, back to /series, "All Series"
Tag detail: 🏷️ Tag, back to /bookshelf, "Bookshelf"

Deleted series_detail.templ and series_detail_templ.go.
Updated frontend.go series route to call BrowseDetail with series params.
Added /tags/detail route calling BrowseDetail with tag params.
2026-05-10 16:11:29 -04:00
john-okeefe a92739d210 fix(handlers): wire all 37 fields in UpdateMediaItem, add cover upload support
UpdateMediaItem handler:
- Add form: tags to UpdateMediaItemRequest for dual JSON/multipart binding
- Add 8 missing fields (Language, Edition, PageCount, Genre, CopyrightYear,
  GoodreadsID, OpenlibraryID, GoogleBooksID)
- Add CoverAction field (keep/upload/remove) with multipart cover handling
- Fetch existing record before update to preserve cover_image_path when
  cover_action is "keep" (was clearing cover on every JSON save)
- Add saveCoverImage() method: validates image type, resolves library path,
  saves as {file_path}.cover.jpg
- Add HX-Redirect response header for HTMX clients

HandleBulkUpdate:
- Copy all 37 fields from existingMedia (was missing GoogleBooksID + 14
  new fields), preventing data loss on bulk metadata updates.
2026-05-10 11:52:04 -04:00
john-okeefe f199918775 fix(scanner): wire all metadata fields in updateMediaItem and skip image dupes
updateMediaItem (used by force rescan) was missing 22 fields including
Language, Genre, PageCount, CopyrightYear, GoodreadsID, and all 14 new
columns from the SQL query fix. Now wires all 37 UpdateMediaItemParams.

Also adds hasSiblingBookFile() early exit in processMediaFile: if a file
is an image (jpg/png/webp/etc) and its directory contains an actual book
file (epub/pdf/cbz/etc), skip importing the image as a standalone media
item. This prevents cover images and interior art from appearing as
duplicate library entries.
2026-05-10 11:51:43 -04:00
john-okeefe a3504f1ae5 fix(db): add 14 missing columns to UpdateMediaItem query
The UpdateMediaItem SQL query only SET 23 of 37 media_items columns,
causing all 3 call sites (admin PUT, bulk update, force rescan) to
silently NULL out the 14 unwired fields on every update.

Added: manga_type, reading_direction, series_count, volume, imprint,
age_rating, web_url, metadata_notes, community_rating, story_arc,
is_black_and_white, alternate_info, scan_information, summary.

Regenerated sqlc Go code (queries.sql.go) with 37-param
UpdateMediaItemParams struct.
2026-05-10 11:51:27 -04:00
john-okeefe 355ae5f7a9 fix(series): remove library switcher from detail page, add title tooltips to BookCard
Remove the library selector dropdown from the series detail page
since the page is scoped to the library from the browse page.
Replace it with a simple '← All Series' back link in the sticky bar.

Add title attributes to the shared BookCard template so the full
book title and author are visible on hover (useful for truncated
text with line-clamp).
2026-05-08 20:58:43 -04:00
john-okeefe ddc14e3314 feat(series): add dedicated series detail page instead of bookshelf filter
Create a new /series/detail?name=X&library_id=Y SSR page that shows
all books in a specific series, replacing the broken approach of
linking to /bookshelf?series_filter=X (the bookshelf SSR handler
ignores all filter query params).

The series detail page features:
- Back link to /series browse page
- Library selector dropdown (full page navigation on change)
- Series name header with book count badge
- Book grid using the shared BookCard template
- Empty state for series with no books

Update all links to point to the new page:
- Series cards on /series browse page
- Series badge on book detail page
- JS-rendered cards in series.ts switchLibrary

Add seriesDetailPage Alpine component for the detail page's
library switcher (simple navigation, no AJAX needed).
2026-05-08 20:50:59 -04:00
john-okeefe cce7ad4907 test(series): add unit and integration tests for series feature
Unit tests:
- series_service_test.go: test continueSeriesRowToMediaItems conversion
  with valid fields, null fields, and comprehensive field mapping
- series_test.go: test handler initialization and textToString helper

Integration tests (series_integration_test.go):
- GET /api/series: requires library_id, rejects invalid UUID, returns
  empty array for empty library, pagination params, limit clamped to
  100, response structure validation, special characters in names
- GET /api/series/books: requires library_id and name, handles
  nonexistent series, unauthorized access
- Restore Continue Series system collection
- Dashboard sections include all 5 collections (including continue-series)

Update test helpers:
- Add SeriesHandler to setupTestServer router config
- Add 5th Continue Series collection to createDefaultCollectionsForUser
- Update dashboard integration test for 5 collections
2026-05-08 20:27:40 -04:00
john-okeefe 004b761381 feat(series): add SeriesHandler, API routes, and SSR browse page
Create SeriesHandler with two API endpoints:
- GET /api/series (paginated series list with covers)
- GET /api/series/books (books in a specific series)
Uses query param ?name=X instead of path param to avoid URL encoding
issues with special characters in series names.

Add GetSeriesCardsData helper returning services.SeriesInfo for use
by the SSR route (avoids handlers→templates import cycle).

Register /api/series routes via registerSeriesRoutes in router.
Add /series SSR route in frontend.go with library-scoped pagination
and error handling, matching the dashboard/bookshelf patterns.

Add SeriesHandler to router Config and instantiate in main.go.

Add SeriesCardData type to templates/types.go.

Add Continue Series as the 5th valid system collection in
dashboard handler and auth handler's CreateDefaultCollectionsForUser.
2026-05-08 20:27:01 -04:00
john-okeefe 9405afa2c5 feat(series): add SeriesService and wire continue-series into dashboard
Create SeriesService with methods for paginated series listing, cover
path resolution, series book listing, and a conversion helper for
GetContinueSeriesItemsRow to MediaItems.

Wire the continue-series query type into DashboardService's
getCollectionItemsByQueryType switch and add its metadata to the
RestoreSystemCollection default collection map.
2026-05-08 20:26:47 -04:00
john-okeefe d48404e800 feat(series): add SQL queries for series browsing and continue-series
Add five new sqlc queries to support the series browse page and
continue-series dashboard collection:

- GetDistinctSeries: list unique series with book counts, sorted by
  most recent entry, with pagination
- GetDistinctSeriesCount: total distinct series count for pagination
- GetSeriesCovers: fetch up to N cover image paths for a series,
  ordered by series_number
- GetSeriesBooks: fetch all books in a series ordered by series_number
- GetContinueSeriesItems: CTE-based query using DISTINCT ON to find
  the next unread book per series for a given user/library, sorted
  by most recent last_read_at
2026-05-08 20:26:39 -04:00
john-okeefe 607ce8ce3a Merge branch 'main' of ssh://git.linuxhg.com:2222/Bookhoard/bookhoard 2026-05-01 14:31:54 -04:00
john-okeefe ff0517d038 fix(library): sync allowed extensions across service, schema, and tests
Add .epub and .pdf to comics type, add .pdf to manga type, and ensure
avif/tiff/tif extensions are consistently included in manga across all
layers. Update test fixtures to match the canonical extension lists.
2026-05-01 14:31:17 -04:00
john-okeefe a9f8e23ddd feat(ui): expand timezone dropdowns to cover all populated UTC offsets
Replaced the 8 US-centric timezone options with 24 entries covering
every populated UTC offset worldwide (UTC-10 through UTC+12). Each
option is labeled by regional name with UTC offset in parentheses,
e.g. 'Central European (UTC+1/+2)'. DST-shifting zones show both
standard and daylight offsets.

Covers: Hawaii, Alaska, Pacific, Mountain, Mountain-no DST, Central,
Eastern, Brasilia, British, Central European, Eastern European,
Moscow, Iran, Gulf, Pakistan, India, Bangladesh, Indochina, China,
Japan/Korea, Australian Central, Australian Eastern, New Zealand.

Backend already validates all IANA zones via time.LoadLocation(), so
users with uncommon zones can still set them via the API.

Updated in three locations:
- templates/profile_form.templ (user profile dropdown)
- templates/admin_settings.templ (admin settings dropdown)
- internal/handlers/sidecar.go (HTMX save response HTML)
2026-04-29 20:47:11 -04:00
john-okeefe 1da7765466 fix(admin): wire up default timezone setting in admin settings page
The admin settings timezone dropdown was incomplete: it had no
pre-selection of the current value, was missing consistent styling,
and the form submission did not persist timezone changes.

Changes:
- frontend.go: load default_timezone from system_settings into the
  systemConfig map passed to the template
- admin_settings.templ: match card styling used by the Base URL
  section; pre-select current timezone with selected?= attribute
- sidecar.go: handle default_timezone in UpdateSystemConfiguration
  by writing to system_settings table instead of system_config;
  update HTMX response to include timezone section with current value
- Add selectedAttr() helper for HTMX HTML string response
2026-04-29 20:32:58 -04:00
john-okeefe f87fc45377 feat(db): add timezone column to GetUser query
The GetUser query did not select the timezone column, so the router
helper could not access userDB.Timezone. Added u.timezone to the
SELECT list so the per-user timezone is available in the template
user context.
2026-04-29 20:32:42 -04:00
john-okeefe 5be6fec408 fix(auth): resolve compile errors in timezone update handler
The timezone update block in UpdateProfile() referenced undefined
variables ctx and userUUID, causing a compile error. Fixed to use
c.Request().Context() and targetUserUUID which are the correct
variables in that handler scope.

Also added Timezone field to AdminUpdateUserRequest struct so the
timezone value is properly bound from JSON requests, since
UpdateProfile() binds to AdminUpdateUserRequest rather than
UpdateProfileRequest.
2026-04-29 20:32:38 -04:00
john-okeefe e5726e12be Switch all user-facing time displays to 12-hour MM-DD-YYYY format
Consistently format dates and times across all templates and API
handlers using MM-DD-YYYY with 12-hour clock (03:04 PM):

- analytics.go: date keys, lastSync, lastRead timestamps
- progress.go: lastUpdated timestamp in GetAllProgress
- book_detail.templ: LastReadAt, DatePublished
- book_detail_modals.templ: progress sync timestamps, LastReadAt
- devices.templ: LastSync, LastSeen
- conflicts.templ: CreatedAt
- admin_users.templ: user CreatedAt date
2026-04-27 21:31:19 -04:00