Author SHA1 Message Date
john-okeefe 122038123c 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 19fc222fb3 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 e047a627a8 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 668aee4e22 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 2e2121515e 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 3c3f16ea1b 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 2d01ec52fe 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 6e2d304b35 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 43d8afc6cd 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 9b31dc6f68 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 0e8b11504e 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 59cb0e3439 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 158fcb510b 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 8cc9f75a1a 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 a6e6913ec4 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 c41e4af8b0 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 55a299540c 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 c4f972aba1 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 da3287ef4d chore(server): call SyncAllowedExtensions on startup 2026-05-16 19:31:09 -04:00
john-okeefe 797726b68e 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 ec6844bed0 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 dea952020c 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 13bb2975f8 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 4b3433af7d 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 d225e1dff4 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 e57daed448 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 6782d7a741 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 fdf4c9ac35 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 413503b9b3 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 37177516e0 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
john-okeefe 5bdddc6538 fix(templates): fix ErrorToast rendering literal { message } instead of error text
ErrorToast used Go string literal '{ message }' instead of templ
interpolation, so the actual error message was never shown — just the
literal text "{ message }" appeared in the toast.
2026-05-10 16:13:21 -04:00
john-okeefe be1c373b19 feat(metadata-editor): replace tags text input with badge picker + autocomplete
Replace the comma-separated text input for tags in the metadata editor
with a badge-based tag picker:
- Current tags shown as removable pill badges (✕ button per tag)
- Autocomplete input queries existing tags via shared tag-dropdown module
- Results shown as inline dropdown (not absolute, avoids overflow clipping
  from the modal's overflow-y-auto content area)
- Keyboard navigation: ArrowUp/Down, Enter to select, comma to add new,
  Escape to close
- Supports adding new tags not in the database (type + Enter/comma)
- Hidden data-editor-tag spans seed initial tags from server-side render
- collectFormData() now accepts editorTags array, skips the removed
  tags text input
2026-05-10 16:13:07 -04:00
john-okeefe fb9c471959 feat(bookshelf): replace broken datalist tag filter with custom autocomplete
The HTML <datalist> approach for tag autocomplete was unreliable across
browsers — showed empty suggestions or no dropdown at all.

Replace with a custom Alpine.js dropdown:
- New tag-dropdown.ts shared module: searchTagSuggestions() queries
  /api/media-items/search?tags=...&library_id=... and returns results
- Bookshelf: absolute-positioned dropdown below tags_filter input, shows
  tag name + book count per suggestion
- Keyboard navigation: ArrowUp/Down to highlight, Enter to select,
  Escape to close
- Click suggestion to populate the filter input
2026-05-10 16:12:44 -04:00
john-okeefe 3e5d3fe043 feat(book-detail): add tags and contributors display, fix series/tag links
- Add clickable tag badges between comic badges and synopsis, linking to
  /tags/detail?name=<tag>&library_id=<id> for browsing books by tag
- Add Contributors as comma-separated row in the metadata grid
- Add data-library-id attribute to body for tag autocomplete API calls
- Fix series badge link: append &library_id= so /series/detail works
  when navigated from book detail page (was returning empty/404)
2026-05-10 16:12:28 -04:00
john-okeefe b741f4f32d 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 647dec676f 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 0acacc1aa0 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 1724bc0767 feat(frontend): wire metadata editor Alpine component in book-detail.ts
Replace placeholder toast with full metadata editor Alpine data component:
- Modal show/hide (showMetadataEditor, hideMetadataEditor)
- Accordion section toggle
- Cover upload via FileReader preview
- Cover generation via dynamic cover-generator import
- Cover removal with placeholder fallback
- saveMetadata(): collects form data, sends PUT as JSON or multipart
  depending on whether a cover file is present
- Back button fix: skip overwriting sessionStorage back URL when
  referrer is the current page (preserves navigation after page reload)
2026-05-10 11:53:50 -04:00
john-okeefe 2ef1f9580f feat(frontend): add client-side cover generator via dynamic foliate-js import
New cover-generator.ts module that dynamically imports foliate-js/view.js
only when cover generation is requested, keeping it out of the main bundle.

Supports all media types:
- PDF (fixed_layout): renders page 1 to canvas via view.renderer
- EPUB/CBZ (reflowable): extracts book.cover blob from parsed metadata
- Falls back to canvas-to-JPEG conversion for non-JPEG sources
2026-05-10 11:53:31 -04:00
john-okeefe 4a26e79a20 feat(book-detail): wire metadata editor button and add format-group data attr
- Replace showMetadataEditorPlaceholder() toast with showMetadataEditor()
  that opens the metadata editor modal
- Add data-format-group attribute to body for client-side cover generation
- Include @MetadataEditorModal(book) in the page modals section
2026-05-10 11:53:11 -04:00
john-okeefe 1ae9e08f43 feat(templates): add metadata editor modal with cover management
New MetadataEditorModal component with:
- Cover section (w-64 h-96, matching book detail page layout): click-to-upload,
  Generate Cover button, Remove Cover button
- Accordion sections: Basic Info, Publication, Series, Identifiers,
  Comic/Manga, Technical — covering all 34 editable metadata fields
- Modal capped at 90vh with scrollable content area
- Cover upload via hidden file input with hover overlay
- Select dropdowns for MangaType and ReadingDirection
- Read-only display for Format and File Size
2026-05-10 11:52:53 -04:00
john-okeefe e242ad3e00 feat(templates): add helper functions for metadata editor form rendering
Add textToString, tagSliceToString, stringSliceToString, and
formatDateForInput to convert pgtype/[]string values into HTML input
value attributes for the metadata editor form fields.
2026-05-10 11:52:36 -04:00
john-okeefe 0682d0a1cb fix(reader): add explicit PDF.js resource paths and pin foliate-js fork
foliate-js could not locate cmaps and standard_fonts at runtime because
no explicit paths were provided to the PDF.js config. This caused
rendering failures for PDFs using CJK fonts or standard PDF fonts.

Changes:
- Pass cMapUrl and standardFontDataUrl to view.open() in reader.ts
- Pin foliate-js fork to commit 74c317d in package.json for reproducibility
- Update build:ts script to copy cmaps/ and standard_fonts/ to
  web/static/vendor/pdfjs/ during build
2026-05-10 11:52:23 -04:00
john-okeefe 69872b48b5 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 5f3b392168 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 7b9d3562ef 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 93991dfb0a 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 52464581dc 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 9471c4a599 fix(tests): URL-encode series name in special characters test
The TestGetSeries_SpecialCharactersInName test was failing with a 400
status because the series name 'Series: Book & Other (Vol. 1)' was
interpolated directly into the URL without encoding. The ampersand was
parsed as a query parameter delimiter, corrupting the request.

Use url.QueryEscape() to properly encode the name parameter.
2026-05-08 20:31:30 -04:00
john-okeefe 905a5d769c docs: add series cover layout mockup for reference 2026-05-08 20:28:02 -04:00
john-okeefe 9cc371572e chore: regenerate templ Go files (path reference update) 2026-05-08 20:27:50 -04:00
john-okeefe 9c1c5a66ac 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 4cb72fc9ae feat(series): add AJAX library switching and stacked-cascade CSS
Create web/src/series.ts with Alpine component implementing the same
switchLibrary pattern as the dashboard:
- Fetch /api/series on library change instead of full page reload
- Fade out/in transition with loading spinner
- Re-render series grid and pagination from JSON response
- Save selected library to localStorage

Import series.ts in main.ts.

Add stacked-cascade CSS to input.css for multi-cover series cards:
- Covers cascade from top-left to bottom-right with increasing z-index
- Front cover sits at bottom-right (highest z-index)
- Separate layout rules for 1-7 covers with rotation offsets
- Hover lift effect on series cards
2026-05-08 20:27:27 -04:00
john-okeefe 004416a009 feat(series): add series browse template, nav link, and clickable badge
Create templates/series.templ with:
- Library selector dropdown (sticky, same pattern as dashboard)
- Loading spinner overlay for AJAX library switching
- Series grid with stacked-cascade multi-cover cards
- Empty state when no series found
- Pagination with Previous/Next links
- SeriesCard sub-template linking to filtered bookshelf view

Add 'Series' nav link in header between 'All Books' and 'Collections'.

Make series badge on book detail page clickable, linking to
/bookshelf?series_filter=<name>&sort=series.

Add 'Continue Series' option to restore system collection modal.
2026-05-08 20:27:15 -04:00
john-okeefe 2fdc8751b4 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 81c1267dfa 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 864f2cc6b9 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 06985474d0 fix(tests): correct date format in analytics reading stats tests
The GetReadingStats handler expects dates in MM-DD-YYYY format (01-02-2006)
but the tests were sending YYYY-MM-DD (2006-01-02), causing 400 errors on
the GetReadingStats_WithCustomDateRange and ReadingStats_FutureDateRange
test cases. Updated both test functions to use the matching format.
2026-05-01 16:59:49 -04:00
john-okeefe 8c7266e93b Merge branch 'main' of ssh://git.linuxhg.com:2222/Bookhoard/bookhoard 2026-05-01 14:31:54 -04:00
john-okeefe d22446d9b9 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 ae61acf478 chore: remove stale planning documents (PANEL_DETECTION_PLAN, PROGRESS_MIGRATION) 2026-05-01 14:31:13 -04:00
john-okeefe 19a85a3390 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 7534d7c30a feat(config): add TZ environment variable for container timezone
Adds TZ env var to docker-compose.yml app service (defaults to UTC)
and documents it in .env.example. This ensures the Go runtime's
time.Local is set correctly inside the container for any server-side
time operations that don't use an explicit timezone.
2026-04-29 20:33:13 -04:00
john-okeefe 55e668079c chore: regenerate all templ Go files
Regenerated from .templ sources after template changes. Includes
path reference updates in error messages (templates/ prefix
shortened) from templ tool regeneration.
2026-04-29 20:33:08 -04:00
john-okeefe be4ed15dad fix(profile): match timezone dropdown styling to rest of profile form
The timezone select used generic form-group/form-select CSS classes
while all other fields use Tailwind utilities with CSS custom
properties. Updated to use the same w-full px-3 py-2 border rounded
pattern with var(--bg-primary), var(--text-primary), and
var(--border) for visual consistency.
2026-04-29 20:33:03 -04:00
john-okeefe ad0bfcac2c 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 13edfdcf1a feat(ui): use timezone-aware time formatting across all templates
Replace hardcoded .Format() calls with FormatInTimezone() and
FormatTimestamptzInTimezone() helpers so all timestamps display in
the user's selected timezone.

Changes:
- book_detail.templ: remove incorrect templates. package prefix
- book_detail_modals.templ: add User param to ProgressSyncModal so
  timezone is available; convert Timestamp to FormatInTimezone()
- devices.templ: convert LastSync and LastSeen to FormatInTimezone()
- conflicts.templ: convert CreatedAt to FormatInTimezone()
- admin_users.templ: convert CreatedAt to FormatInTimezone() using
  currentUser.Timezone

Note: DatePublished is kept as a plain date format (MM-DD-YYYY) since
it is a pgtype.Date, not a timestamp, and does not need timezone
conversion.
2026-04-29 20:32:51 -04:00
john-okeefe 6b958cab39 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 df989d4c8c 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 59d0389d45 fix(ui): use timezone-aware formatting for Last Read timestamps in book detail and progress sync modal
Replace hardcoded 12-hour Format() calls with FormatTimestamptzInTimezone()
so that the Last Read time respects the user's selected timezone preference.
Both book_detail.templ and book_detail_modals.templ now use the same
timezone-aware helper that was introduced in the timezone support feature.
2026-04-28 21:12:01 -04:00
john-okeefe f69479db44 Update timezone plan: remove duplicate query, use 12-hour format
- Remove UpdateSystemTimezone query from plan; reuse existing
  UpdateSystemSetting with 'default_timezone' as the key parameter
- Update handler code example to reference UpdateSystemSetting
- Update FormatInTimezone format string to 12-hour (03:04 PM)
- Update queries file description in summary table
2026-04-27 21:31:31 -04:00
john-okeefe 17281e4ce7 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
john-okeefe 3b5af3beae Add timezone dropdown to profile form and admin settings
- Add timezone select dropdown to profile form with common US
  timezones and UTC
- Add system default timezone setting to admin settings page
- Reformat profile_form.templ with consistent indentation and
  multi-line attribute formatting
2026-04-27 21:31:04 -04:00
john-okeefe 330df97cfd Add timezone backend support (handlers, utilities, user context)
- Add FormatInTimezone and FormatTimestamptzInTimezone helpers
  in templates/utils.go for timezone-aware time display
- Add Timezone field to templates.User struct
- Pass user timezone from DB to template context in helpers.go
- Add timezone update handling in auth.go UpdateProfile with
  validation via time.LoadLocation
- Add UpdateTimezoneSettings handler in system_settings.go for
  admin system-wide default timezone using UpdateSystemSetting
2026-04-27 21:30:53 -04:00
john-okeefe 19701dc659 Add timezone support to database schema and queries
- Add timezone column (VARCHAR(50) DEFAULT 'UTC') to users table
- Add default_timezone row to system_settings seed data
- Add idx_users_timezone index for user timezone lookups
- Add UpdateUserTimezone and GetSystemTimezone queries
- Regenerate sqlc code (models, querier, queries.sql.go)
- Reuse existing UpdateSystemSetting for system timezone updates
  instead of creating a redundant UpdateSystemTimezone query
2026-04-27 21:30:37 -04:00
john-okeefe c3d900512c Remove completed PROGRESS_MIGRATION.md
The progress reading history migration has been fully implemented
and this planning document is no longer needed.
2026-04-27 21:30:22 -04:00
john-okeefe d97b144ce9 docs: add TIMEZONE_PLAN.md with full timezone implementation plan
Documents the approach for adding per-user timezone support with
system-wide fallback. The database already stores all timestamps as
UTC via TIMESTAMPTZ columns, so the work is primarily in the display
layer: user preference storage, timezone-aware template helpers, and
UI controls for selecting a timezone.

Covers 9 phases: schema changes, sqlc queries, template utilities,
user context updates, handler changes, profile/admin UI, template
time display conversion, docker config, and testing/deployment steps.
2026-04-26 21:21:49 -04:00
john-okeefe d222257797 feat(ui): persist library selection across page navigation
Save the selected library to localStorage when the user changes the
dropdown on the bookshelf page, and restore it on every page load via
a new restoreLibrarySelection() call in the header Alpine component.

This ensures that when a user navigates between dashboard, bookshelf,
collections, etc., their last-chosen library filter is automatically
re-applied rather than resetting to the default.

Changes:
- web/src/bookshelf.ts: listen for change events on #library-select
  and persist the value to localStorage
- web/src/header.ts: add restoreLibrarySelection() which checks
  localStorage and sets the matching dropdown option on page load
- templates/header.templ: call restoreLibrarySelection() in x-init
- templates/header_templ.go: regenerated from templ source
2026-04-26 21:21:40 -04:00
john-okeefe 7f66a62d45 fix(tests): repair TestUnifiedSearch and TestWebSocketProgressBroadcast
TestUnifiedSearch: Search for 'zzzznonexistent' instead of 'test' which
matches leftover test data from other tests. Fixes false 200 instead of 404.

TestWebSocketProgressBroadcast: Update to new progress endpoint
/api/media-items/:id/progress with correct PUT body format matching
ProgressService (percentage, epubcfi). Use book_id instead of
media_item_id to match WebSocket broadcast payload field names.
2026-04-25 21:34:58 -04:00
john-okeefe 630283ab3f docs: add PROGRESS_MIGRATION.md with full plan, bug list, and execution order
Documents the ProgressService migration including: data loss bug analysis,
handler-by-handler migration plan, route changes, test strategy, and
known issues for future work (conflict_detected column never set to true,
offline detector not started, server-side CFI generation needs Go EPUB
parser).
2026-04-25 21:17:19 -04:00
john-okeefe 9a32d89a9b test(progress): add comprehensive integration tests for ProgressService
Adds 30 integration tests across 7 test functions covering all progress
endpoints with real HTTP requests and database verification:

- AuthContexts (8 tests): unauthenticated PUT/GET return 401, regular
  user and admin both get 200, invalid UUID returns 400, nonexistent
  item returns 200 with empty data.

- MergePreservesFields (2 tests): second PUT with only percentage
  preserves epubcfi and chapter from first save via GET verification;
  web save preserves koreader character_offset via DB query.

- EnrichmentComputesFields (2 tests): character_offset computed from
  percentage when total_characters is set on media item; GET returns
  enriched format_group and total_characters.

- ConflictDetection (3 tests): different sources with >1% diff within
  5 minutes creates sync_conflicts record; same-source rapid saves
  create no conflict; <1% diff creates no conflict.

- KoboIntegration (3 tests): ReadingSync then last-read-place preserves
  percentage via DB; standalone last-read-place sets epubcfi/chapter;
  unauthenticated returns 401.

- KOReaderIntegration (2 tests): Bearer token auth with proper request
  body returns 202 Accepted; unauthenticated returns 401.

- DeleteProgress (2 tests): DELETE clears progress; unauthenticated
  returns 401.

- EdgeCases (4 tests): empty body succeeds, 0.0% and 1.0% boundaries,
  all fields with full DB verification of each column.

Updates test_helpers to create ProgressService in setupTestServer and
inject into all handlers. Fixes previous tests that used testing.Short()
(which caused all tests to be skipped in the container) and assertions
against wrong JSON format (pgtype serializes as plain values, not
wrapped objects).
2026-04-25 21:17:08 -04:00
john-okeefe 82d0d378a6 feat(reader): send richer progress payload with chapter boundaries and zoom
The reader's saveProgress() now sends a more complete payload to the
backend so ProgressService has more data for enrichment and merge:

- chapter: computed from TOC boundary index instead of missing
- reading_mode: current display mode (page, chapter, percent, time-left)
- zoom_level: for fixed-layout books (renderer.zoomPercent / 100)
- current_page: real page number for fixed-layout, location.current for
  reflowable
- total_pages: section count for fixed-layout, location.total for
  reflowable

Adds computeChapterPageBoundaries(doc) for reflowable EPUBs that maps
TOC anchors to rendered page numbers, recomputes after fonts load.
Adds computeFixedLayoutChapterBoundaries() for fixed-layout books that
resolves TOC hrefs to page indices via view.resolveNavigation().

Updates reader.templ to expose isFixedLayout to Alpine init.
2026-04-25 21:16:52 -04:00
john-okeefe a635c6d46e refactor(router): remove duplicate progress routes, add ProgressService to config
- Remove GET /progress/:id and POST /progress/:id from progress routes.
  These were superseded by the media-item progress routes. Only
  GET /progress/:id/history remains.

- Add ProgressService to router.Config so sync.go can inject it into
  KoboHandler via SetProgressService().

- Inject ProgressService into KoboHandler at route registration time
  rather than requiring a separate setup step.

- Update comment from 'Legacy progress routes' to 'Progress routes'.
2026-04-25 21:16:39 -04:00
john-okeefe 1cda4e5191 feat(handlers): integrate ProgressService into media, koreader, kobo, and queue
All four progress write paths now delegate to ProgressService.SaveProgress:

- MediaHandler: UpdateMediaReadingProgress uses ProgressService for web
  saves with richer request body (reading_mode, zoom_level, scroll). GET
  now uses GetUniversalProgress query that JOINs media_items for
  format_group, total_characters, chapter_count.

- KOReaderHandler: updateProgressForBook delegates to ProgressService.
  Fixed device ID bug (was using userID, now uses deviceID). Removed
  duplicate UpdateDeviceLastSync with zero UUID. Added pgtype helper
  functions (textPtrToPgText, intPtrToPgInt4, int64PtrToPgInt8).

- KoboHandler: all four progress write points (Markup ReadingSync, Markup
  last-read-place, AnalyticsGettests, SyncFromServer) delegate to
  ProgressService. Fixed empty epubcfi string now correctly set to
  Valid: false. SyncFromServer preserves last_sync_source=bookhoard
  and Broadcast: false.

- QueueProcessor: syncProgress delegates to ProgressService.

- main.go: creates ProgressService after ConnectionManager, injects via
  SetProgressService() on all handlers and queue processor.

Handler tests cover pgtype conversion helpers (textPtrToPgText, etc.)
and device icon mapping.
2026-04-25 21:16:29 -04:00
john-okeefe d8330e8d0a feat(sync): add ProgressService with merge, enrichment, and conflict detection
Introduces a centralized ProgressService that handles all reading progress
writes across web, KOReader, and Kobo clients. The service implements:

- Merge strategy: reads existing progress first, then only overwrites
  non-nil fields from the incoming request. This fixes the data loss bug
  where partial updates (e.g., Kobo last-read-place sending only epubcfi
  and chapter) would NULL out percentage, character_offset, etc.

- Enrichment: computes missing fields from available data:
  - character_offset from percentage + total_characters
  - current_page from percentage + total_pages
  - percentage from current_page + total_pages (reverse)
  - percentage from character_offset + total_characters (reverse)

- Conflict detection: when a different source writes progress within 5
  minutes with >1% difference, records a sync_conflicts row and broadcasts
  a WebSocket notification for real-time UI alerts.

- Broadcast control: SaveProgressRequest.Broadcast flag lets Kobo
  last-read-place and SyncFromServer skip WebSocket broadcasts.

- Pointer fields on SaveProgressRequest: nil means preserve existing,
  non-nil means overwrite. Eliminates ambiguity between zero values
  and not-provided fields.

Also adds unit tests for buildProgressSnapshot helper function.
2026-04-25 21:16:15 -04:00
john-okeefe 9ac24a1eac chore(templates): update FileName references to include templates/ path prefix in generated Go files
All 25 templ-generated Go files had their error-handling FileName fields
updated from bare filenames (e.g. `dashboard.templ`) to path-prefixed
filenames (e.g. `templates/dashboard.templ`). This reflects a change in
how the templ compiler resolves source file paths, likely due to running
generation from the project root instead of within the templates directory.
The change is purely cosmetic and only affects runtime error messages,
not application behavior.

Affected templates:
- Admin: library, processing_issues, settings, sidebar, users
- Reader/Book: book_detail, book_detail_modals, bookshelf
- Collections: collection_modal, collection_rules, collections
- Other pages: conflicts, custom_section, dashboard, devices,
  docs, error, filter_item, header, profile_form, profile_modal,
  progress, queue, unlinked_books
- API: api_explorer
2026-04-25 13:41:20 -04:00
john-okeefe e66308c323 feat(reader): wire up progress mode switching with four display modes
Connect the existing progress_mode setting dropdown to the reader's
progress display. Four modes are now functional:
- pages: overall percent + page/location number (default, existing)
- chapter: chapter title + page X / Y within current section
- percentage: overall percent only
- time-left: percent + estimated time remaining via reading speed API
The progress display in the bottom bar is now clickable to cycle through
modes with immediate visual feedback. The settings dropdown is bound
with x-model for persistence. Reading speed is fetched once on init
from the backend reading-speed API for time-left estimates.
2026-04-25 13:40:18 -04:00
john-okeefe f24e354549 feat(types): add FoliateTocItem interface for foliate-js TOC entries
Add a typed interface for the tocItem data returned by foliate-js
relocate events, replacing untyped usage in the reader progress display.
2026-04-25 13:39:07 -04:00
john-okeefe 2a1ff77173 chore(templates): regenerate all templ generated Go files
Regenerated all _templ.go files after running templ generate. Changes
include updated FileName references (relative path normalization) and
line number adjustments from the templ code generator.
2026-04-24 14:03:16 -04:00
john-okeefe 9863b2082c fix(progress): correct percentage display and add format-aware progress
Fix two bugs in progress display across book detail, progress page, reader,
and sync modal templates:

1. Percentage was stored as 0.0-1.0 fraction but displayed as-if 0-100
   (showing 0.5% instead of 50%). Multiply by 100 at the data source in
   both GetAllProgress and GetAllProgressData handlers, and in the reader
   route's ReadingProgress construction.

2. Progress bar width was never evaluated — { expr } inside style=".."
   was rendered as literal text by templ, resulting in 0% width bars for
   all items. Fixed by using templ's style={ expr } attribute syntax
   which evaluates the Go expression (uses SanitizeStyleAttributeValues).

Also add format-aware progress display:
- Reader template: shows "45% · Page 89/196" for reflowable (estimated
  pages), "127/342" for comics/PDFs (actual pages)
- Progress page: shows "Page X of Y (est.)" for reflowable, "X / Y"
  for fixed layout
- Add FormatGroup and EstimatedPages to ProgressWithMedia struct
- Remove hardcoded totalPages=200 fallback in progress handler (now 0)
- Add fmt import to progress.templ for string formatting
2026-04-24 14:03:03 -04:00
john-okeefe 981911077b feat(templates): expand reader and progress template types for format-aware display
Add fields to ReaderMetadata and ReadingProgress template types to support
KOReader-like progress display:

ReaderMetadata:
- TotalCharacters: from media item, used for estimated page calculation
- EstimatedPages: computed via sync.EstimatedPages()

ReadingProgress:
- Chapter: current chapter index from reading_progress
- ChapterProgress: within-chapter progress (0-100, multiplied from DB fraction)
- FormatGroup: item format for conditional display logic

These fields enable format-aware progress display (pages for comics/PDFs,
estimated pages for reflowable, percentage for all).
2026-04-24 14:02:43 -04:00
john-okeefe 3bc6b0f477 feat(sync): add estimated pages calculation for reflowable ebooks
Reflowable ebooks (EPUBs) don't have inherent page numbers since layout
depends on device settings. Add an EstimatedPages() function that converts
total character count to an estimated print page count using the industry
standard of 1800 characters per page.

This provides a consistent, device-independent page count for progress
display (e.g., "Page 89 of 196" for a reflowable EPUB), matching how
KOReader and similar readers handle the same problem.
2026-04-24 14:02:28 -04:00
john-okeefe be4f89fdd2 fix(reader): persist chapter metadata cache to database
The DetectChapters function in reader.go was serializing chapter detection
results to JSON but then discarding the bytes with `_ = metadataBytes`
instead of writing them to the database. This meant chapter_metadata in
media_items was never populated, forcing re-detection on every request.

Replace the no-op discard with an actual UpdateMediaItemChapterMetadata()
call using the existing sqlc-generated query.
2026-04-24 14:02:13 -04:00
john-okeefe 93197b2e31 fix(scanner): populate page count and total characters during media scanning
The media scanner never populated page_count or total_characters in
media_items, leaving progress display and reading position calculations
with no reliable data. This commit fixes data population for all formats:

Comics (CBZ/CBR/CB7/CBT):
- Add countArchiveImages() helper that walks archive entries and counts
  image files (.jpg, .jpeg, .png, .gif, .webp)
- Call it during comic metadata merge to set metadata.PageCount

PDFs:
- Extract pdfInfo.PageCount from the pdfcpu library (already available
  from PDFInfo call, just never used) and set metadata.PageCount

Reflowable EPUBs:
- Use book.AllChaptersText() to compute metadata.TotalCharacters
- Use book.ChapterCount() to set metadata.ChapterCount

Fixed-layout EPUBs (manga/comics in EPUB format):
- Merge .epub into the .cbz case in countArchiveImages since both are
  ZIP archives with images
- Detect fixed-layout EPUBs via DetectFixedLayoutEPUB() in both the
  Calibre sidecar path (mergeMetadata) and the no-sidecar path
  (extractMetadata), counting images when fixed-layout is detected

Format group on creation:
- Remove the guard condition on UpdateMediaItemFormatGroup so that
  format_group, is_reflowable, and has_fixed_layout are set immediately
  for every new item (not just items with text data)
- Use DetectFixedLayoutEPUB() instead of hardcoding all .epub as
  reflowable, correctly classifying fixed-layout EPUBs

Also pass PageCount to CreateMediaItem and add PageCount,
TotalCharacters, and ChapterCount fields to the MediaMetadata struct.
2026-04-24 14:01:57 -04:00
john-okeefe 163b3162b9 feat(reader): wire up reading progress save and restore
The web reader had all the infrastructure for progress persistence
(updateReadingProgress/getReadingProgress API functions, PUT/GET
endpoints, database queries) but the reader.ts never called them.

Changes:
- Add debounced (2s) saveProgress call on every relocate event that
  PUTs percentage, current_page, total_pages, and epubcfi to the
  existing /api/media-items/:id/progress endpoint
- Replace renderer.next() with view.init({ lastLocation }) to restore
  the reader to the last saved position on load (CFI first, then
  fraction fallback, then default first page)
- Pass savedPercentage and savedCfi from server-side progress data
  through readerInitExpr config to the JS initReader function
- Add mediaItemId and saveTimeout to the Alpine data object

This fixes both the blank /progress page and the missing progress
section on book detail pages — both were empty because the
reading_progress table never received any data from the web reader.
2026-04-23 21:08:30 -04:00
john-okeefe 2ee37c657c fix: replace invalid new(expression) calls with proper pointer allocation
Go's new() builtin takes a type and allocates a zero value — it cannot
wrap an expression. All instances of new(someExpression) were compile
errors. Replace each with a local variable assignment and address-of
operator.

Affected files:
- handlers/koreader.go: progress field pointers (Chapter, Page, etc.)
- handlers/kobo.go: pagesRemaining pointer
- handlers/queue.go: uuidPtrToString and timestamptzPtrToString helpers
- router/reader.go: bookmark pageNumber and chapterNumber pointers
- services/media_scanner.go: validation error message pointers
- services/worker.go: StartedAt and CompletedAt timestamps
- sync/offline.go: GetDeviceStatus return pointer
- tests/device_test.go: SyncEnabled and SyncFrequencyMinutes pointers
2026-04-23 20:39:50 -04:00
john-okeefe 065099cfc2 fix(reader): URL-encode file paths and JSON-encode init config to fix comics/manga loading
The reader failed to load comics and manga (and any file with special
characters in its path) for two reasons:

1. FileURL was built with raw fmt.Sprintf instead of ResolveMediaURL,
   so characters like '#' in paths (e.g. 'Annual #2') were interpreted
   as URL fragments, truncating the path and causing 404s.

2. The Alpine x-init expression used raw string interpolation for config
   values, so apostrophes in paths (e.g. "I'll Use My Appraisal Skill")
   broke JavaScript parsing with 'Unexpected identifier'.

Fix by using utils.ResolveMediaURL for proper URL path encoding and
json.Marshal for the initReader config to safely escape all special
characters.
2026-04-23 20:39:36 -04:00
john-okeefe dac7c03ce7 build: regenerate CSS after dashboard changes 2026-04-23 17:01:30 -04:00
john-okeefe abef40575f fix(dashboard): use anchor tags for client-side rendered book cards
When switching libraries via TypeScript, renderBookCard() built book
cards as <div> elements with data-action="view-book" for event
delegation, but the click handler was commented out — making books
unclickable after any library switch. The SSR path used proper <a> tags.

Now renderBookCard() wraps cards in <a href="/media/{id}"> to match the
SSR BookCard template, so book links work identically regardless of
whether content was server-rendered or client-rendered.

Also removed the dead view-book handler code and viewBook() stub.

Additionally fixed a listener re-registration bug where the input and
library-select change listeners were nested inside the click callback,
causing them to be registered N times after N clicks. Moved them to
initDashboard() scope so they register exactly once.
2026-04-23 17:01:20 -04:00
john-okeefe 5cdae6cc4b fix(reader): use proper templ expression for back link href and clean up formatting
The back link in ReaderChrome used literal curly braces inside the href
attribute string (href="/media-items/{ metadata.MediaItemID }") which
doesn't interpolate the variable in templ. Changed to use the correct
templ expression syntax: href={ "/media/" + metadata.MediaItemID }.

Also fixed minor formatting issues:
- Normalize whitespace in comment after closing div
- Collapse empty navigator-viewport div to single line
2026-04-23 17:01:01 -04:00
john-okeefe 72c8ba7a4f chore(bruno): mark environment IDs as secrets to prevent cross-machine syncing
Remove hardcoded values for database/environment IDs (media_item_id,
library IDs, collection_id, user_id, etc.) and mark them as secret so
that changing them per machine won't keep syncing to git.
2026-04-23 13:57:35 -04:00
john-okeefe 70ecfe59ff fix(utils): URL-encode media paths to handle special characters in filenames
Cover image URLs with special characters like parentheses, #, ?, or
spaces would break because browsers interpret them as URL delimiters.
Apply url.PathEscape() per path segment in ResolveMediaURL so the
server can correctly resolve files like "Wonder Woman (2016) #001.cbz.cover.jpg".

Also adds a package doc comment and fixes the exported function comment.
2026-04-23 13:57:30 -04:00
john-okeefe 9f778452d6 fix(scanner): extract metadata and covers for comic archives and kepub files
Comic archive formats (.cbz, .cbr, .cb7, .cbt) and .kepub files were
falling through to the default case in extractMetadata(), which only
set the title from the filename. This meant ComicInfo.xml was never
parsed and no cover images were extracted for comics without a Calibre
metadata.opf sidecar file.

The fix adds dedicated switch cases:
- .cbz/.cbr/.cb7/.cbt: calls mergeMetadata() with nil, which triggers
  existing ComicInfo.xml parsing (title, series, issue number, writer,
  publisher, genre, reading direction, etc.) and cover image extraction
  from the archive. Falls back to sidecar cover if no image is found.
- .kepub: treated the same as .epub since KEPUB is an EPUB variant,
  enabling full metadata and cover extraction.
2026-04-22 21:18:53 -04:00
john-okeefe 65c860bb99 fix(tests): handle 404 response for nonexistent library in search filter test
TestCollectionSearchLibraryFilter's 'invalid library_id' case was
expecting a 200 with empty results, but the search handler correctly
returns 404 when no results are found. The test also consumed the
response body for debug logging then tried to JSON-decode the same
body (causing EOF). Add expectedStatus field to the test struct and
return early when a specific non-200 status is expected.
2026-04-22 15:44:01 -04:00
john-okeefe ca315e8913 fix(tests): correct input validation tests for processing issues endpoints
Three issues fixed in processing_issues_test.go:

- Empty UUID: handler returns 400 (uuid.Parse rejects empty string), not 404.
  Fix expectedStatus in both List and Stats validation tests.
- Path traversal: raw '../../' in URL creates extra path segments that don't
  match the route. Use url.PathEscape so the string is treated as a single
  path parameter, letting the handler reject it with 400.
- SQL injection: raw special characters (semicolons, quotes) caused
  httptest.NewRequest to panic. url.PathEscape prevents the panic and the
  handler rejects the decoded value via uuid.Parse.
- Remove unsupported 'audiobooks' library type from
  TestProcessingIssuesDifferentLibraryTypes (only ebooks/comics/manga exist
  in the database schema).
2026-04-22 15:43:54 -04:00
john-okeefe bc199ca268 fix(tests): initialize ProcessingIssuesHandler in test server setup
The setupTestServer() helper in test_helpers_test.go was not creating
a ProcessingIssuesHandler and not passing one to the router config,
causing a nil pointer dereference when any processing issues route was
hit during tests. Add handler creation and wire it into routerConfig
to match how cmd/server/main.go does it.
2026-04-22 15:43:45 -04:00
john-okeefe 01fa49ee1d fix(handlers): use correct route param name 'id' instead of 'libraryId' in processing issues
Both ListProcessingIssues and GetProcessingIssueStats were reading the
URL parameter 'libraryId', but the routes in internal/router/library.go
define the param as ':id'. This caused both endpoints to always fail with
an invalid library ID error since c.Param('libraryId') returns an empty
string that can't be parsed as a UUID.
2026-04-21 21:31:00 -04:00
john-okeefe 0842ae6efa refactor: remove unnecessary type conversions and handle ignored errors across codebase
Remove redundant type conversions that Go 1.26 makes unnecessary or that
were already no-ops:

- uuid.UUID(x.Bytes) → x.Bytes (uuid.UUID is [16]byte, same as pgtype UUID Bytes)
- pgtype.UUID{Bytes: [16]byte(u), Valid: true} → pgtype.UUID{Bytes: u, Valid: true}
- (*time.Time)(&x.Time) → &x.Time
- json.RawMessage(x) → x where x is already []byte
- []byte(stringVal) → stringVal where []byte is expected
- int()/int64()/byte() casts on values already of the target type
- Decompressor(fn) → fn (type is identical)

Handle previously ignored error returns:

- collections.go: check json.Unmarshal error in GetCollection
- conversion_service.go: check fileSize.Scan() error
- app_test.go: check app.Shutdown() error in benchmark
2026-04-21 21:15:59 -04:00
john-okeefe 6519338822 fix(conflicts): use ListConflictsByUser in DismissAllResolved so resolved conflicts are found
DismissAllResolved was calling ListSyncConflictsByUser which filters to
'unresolved' conflicts only, so it could never find the user_resolved or
bulk_resolved conflicts it was trying to delete. The query always returned
an empty set, making dismiss-all a no-op.

Fix the leading space in three SQL query name annotations (ListConflictsByUser,
ListAllConflictsByUserAndStatus, CheckForProgressConflicts) that prevented
sqlc from generating their Go functions. Regenerate the query code and swap
DismissAllResolved to use ListConflictsByUser (no status filter) — the
existing Go loop already filters by resolution_status.
2026-04-21 21:15:34 -04:00
john-okeefe 2569136d3e fix(docker): bump builder to Go 1.26, download sqlc binary, fix test-runner Go version
The builder stage was previously bumped to Go 1.26 but the test-runner stage
remained on Go 1.25, causing 'go.mod requires go >= 1.26.0' errors during
test execution.

Go 1.26 introduced stricter validation of replace directives in dependency
go.mod files, which broke 'go install sqlc@latest' (sqlc v1.31.0 has replace
directives). Replace go install with a direct binary download from GitHub
releases, matching the existing kepubify download pattern.

Bump test-runner from golang:1.25-alpine to golang:1.26-alpine to match the
go.mod requirement.
2026-04-21 21:15:18 -04:00
john-okeefe 96d05886ef fix(tests): handle all Close() and Decode() errors across integration tests
Replace all unhandled resp.Body.Close() calls throughout the test suite:

- Deferred calls: replace 'defer VAR.Body.Close()' with a closure that explicitly
  discards the error via 'defer func(Body io.ReadCloser) { _ = Body.Close() }(VAR.Body)'
- Immediate calls: replace 'VAR.Body.Close()' with '_ = VAR.Body.Close()'

Replace all unhandled json.NewDecoder(VAR.Body).Decode(&x) calls with error capture
and require.NoError assertion. Files using httptest.ResponseRecorder (collections_preview,
processing_issues) use 'err :=' declaration; suite-style tests (scanner_integration,
dashboard_integration) use s.T() instead of t.
2026-04-21 20:33:05 -04:00
john-okeefe c1d3f1ae1d fix(tests): use errors.Is() for error comparison and improve resource cleanup in analytics tests
Replace direct error equality check with errors.Is() in media_scanner_hash_test.

In analytics_test.go, improve defer patterns by capturing resp.Body as a named parameter
to avoid stale references, and add require.NoError() checks on all json.NewDecoder().Decode()
calls that were previously silently ignoring decode errors.
2026-04-20 21:20:38 -04:00
john-okeefe 48725544f5 refactor: use errors.Is()/errors.AsType() for error comparison and rename shadowed variables
Replace direct error equality checks (err == pgx.ErrNoRows, err != http.ErrServerClosed)
with the idiomatic errors.Is() function throughout handlers, services, middleware, and app
startup. This correctly handles wrapped error chains.

Also replace a raw type assertion (*HTTPError) with errors.AsType[*HTTPError]() in the
error handler middleware for consistency.

Additionally, rename shadowed variables for clarity:
- sidecar.go: config -> sidecarConfig, systemConfig (shadowed package-level vars)
- media_scanner.go: uuid -> uuidString (shadowed the uuid package import)
2026-04-20 21:20:22 -04:00
john-okeefe 2fe5fea3d8 feat(reader): add reading_mode (dark/light) to ReaderSettings type
Add the 'reading_mode' field with 'dark' | 'light' values to the
ReaderSettings TypeScript interface, preparing the frontend for a
dark/light reading mode toggle.
2026-04-20 20:45:54 -04:00
john-okeefe a670d379e3 fix(scanner): always attempt cover extraction for EPUBs and relax manga detection
Two changes to EPUB metadata extraction:

1. Restructure extractMetadata so that fixed-layout detection and cover
   extraction always run for EPUBs, even when extractEPUBMetadata returns
   an error. Previously, a partial failure from the EPUB parser would skip
   cover and format detection entirely, leaving books without covers.

2. Remove the language restriction (ja/jpn) from manga reading direction
   detection. Manga tagged with 'manga' should default to RTL regardless
   of the language metadata, since the tag is an explicit signal from the
   user or metadata source.
2026-04-20 20:45:49 -04:00
john-okeefe a19f77c535 fix(sync): prevent nil pointer dereference when existing progress is missing
In applyProgressResolution and applyResolution, currentPage and totalPages
were unconditionally read from existingProgress even when the preceding
query returned err (no rows). This caused a nil pointer dereference when
no existing reading progress existed for a media item. Now declare the
variables as zero-value pgtype.Int4 and only populate them from
existingProgress when err is nil.
2026-04-20 20:45:41 -04:00
john-okeefe be5718de52 refactor(handlers): use errors.Is() for pgx error comparison in KOReader
Replace direct equality checks (err != pgx.ErrNoRows) with the idiomatic
errors.Is(err, pgx.ErrNoRows) pattern. This is the recommended Go practice
for error comparison as it correctly handles wrapped errors from error
chains, making the code more robust against future refactoring that might
wrap errors with fmt.Errorf and %w.
2026-04-20 20:45:35 -04:00
john-okeefe a42f0e3899 fix(sevenzip): add nil guard for subreader to prevent panic
When opening a sevenzip archive, the init() method calls sr :=SevenZipReader()
but never checked if sr was nil before using it. This could cause a nil
pointer dereference when processing malformed or empty archives. Add an
explicit nil check returning errFormat early if the subreader is nil.

Also fixes a minor import grouping whitespace issue.
2026-04-20 20:45:26 -04:00
john-okeefe 4128734362 test(sync): rewrite conflict tests as real HTTP integration tests
Replace the previous mock/httptest-based conflict tests with
integration tests that exercise the full HTTP stack against a live
test server with a real database. Changes include:

- Add shared test helpers (setupConflictTest, createTestConflict,
  makeConflictData) to reduce boilerplate across test files
- Split monolithic TestConflictDetection and TestConflictsBulkOperations
  into focused test functions per scenario
- Test conflict detection, bulk resolution (most_recent, highest_progress,
  manual strategies), and edge cases (empty IDs, invalid UUIDs,
  unauthorized access)
- Verify actual database state after resolution, not just HTTP response
2026-04-20 20:43:16 -04:00
john-okeefe a56061f2b3 test(handlers): add unit tests for conflict resolution logic
Add table-driven tests for the conflict handler's source selection
methods: GetMostRecentSource, GetHighestProgressSource, and
GetEarliestSource. Covers cases where each device wins, ties, and
missing/invalid data.
2026-04-20 20:43:04 -04:00
john-okeefe 533abaf9d7 refactor(docs): replace deprecated strings.Title with cases.Title
strings.Title has been deprecated since Go 1.18 because it does not
handle Unicode properly. Replace it with cases.Title from
golang.org/x/text which correctly handles language-specific title
casing. Applied to breadcrumb generation and document title formatting.
2026-04-20 20:42:58 -04:00
john-okeefe 441d30a63a chore: bump Go dependencies
- github.com/andybalholm/brotli 1.2.0 -> 1.2.1
- github.com/go-playground/validator/v10 10.30.1 -> 10.30.2
- github.com/jackc/pgx/v5 5.9.1 -> 5.9.2
- github.com/labstack/echo/v5 5.0.4 -> 5.1.0
- github.com/yuin/goldmark 1.7.17 -> 1.8.2
- golang.org/x/crypto 0.49.0 -> 0.50.0
- golang.org/x/text 0.35.0 -> 0.36.0
- golang.org/x/image 0.37.0 -> 0.39.0
- golang.org/x/net 0.52.0 -> 0.53.0
- golang.org/x/sys 0.42.0 -> 0.43.0
- Various indirect dependency updates
2026-04-20 20:42:52 -04:00
john-okeefe d4e65a79f9 docs: add fish-to-zsh conversion plan
Add a structured plan for translating Fish shell config.fish into
equivalent Zsh .zshrc syntax, preserving the existing Forge-managed
block in the target file.
2026-04-20 20:42:44 -04:00
184 changed files with 14457 additions and 4684 deletions
+4 -1
View File
@@ -22,4 +22,7 @@ logs/
node_modules/
# Environment
.env
.env
# Media uploads (bind-mounted at runtime)
uploads/
+3 -1
View File
@@ -19,4 +19,6 @@ DBPASS=your-secure-database-password-here
# Conversion Tool: Switch from kepubify to ebook-convert
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
# Conversion Cache TTL: Override default 24h
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
# System timezone (fallback for server-side time operations, defaults to UTC)
# TZ=America/New_York
+3
View File
@@ -68,6 +68,9 @@ Thumbs.db
# Uploads
uploads/
# Vendored build artifacts (copied from node_modules at build time)
web/static/vendor/
# Database
*.db
*.sqlite
+6 -4
View File
@@ -1,5 +1,5 @@
# Build stage
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
WORKDIR /app
@@ -7,9 +7,11 @@ WORKDIR /app
RUN apk add --no-cache nodejs npm curl git
# Install Go tools (cached well)
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
rm /tmp/sqlc.tar.gz
RUN --mount=type=cache,target=/root/go/pkg/mod \
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \
go install github.com/a-h/templ/cmd/templ@latest
go install github.com/a-h/templ/cmd/templ@v0.3.1020
# Copy package files and install npm dependencies (cached unless package.json changes)
COPY package*.json ./
@@ -38,7 +40,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
# Test runner stage - includes Go runtime and test dependencies
# This stage is ONLY used for running tests, never deployed to production
FROM golang:1.25-alpine AS test-runner
FROM golang:1.26-alpine AS test-runner
RUN apk --no-cache add ca-certificates curl
-777
View File
@@ -1,777 +0,0 @@
# Panel Detection Implementation Plan
## Overview
Multi-tier panel detection system with fallback chain:
**OpenCV → ML (COCO-SSD) → Grid → Manual Editor**
Designed for a constantly growing library - handles any comic style without custom training.
---
## Detection Pipeline
```
1. OpenCV Edge Detection (Primary)
├─ Fast, lightweight (~500KB lazy-loaded)
├─ Works on 80% of comics with clear panel borders
└─ Future-proof: works on unknown future comics
2. ML Detection (COCO-SSD Fallback)
├─ Pre-trained on millions of diverse images
├─ Handles irregular layouts
└─ ~2MB (TensorFlow.js) + ~2MB (model), lazy-loaded
3. Grid Detection (Baseline)
└─ Always works as final fallback
4. Manual Editor (Last Resort)
└─ User manually draws panels
```
---
## Dependencies
Add to `package.json`:
```json
{
"dependencies": {
"@techstark/opencv-js": "^4.12.0",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow-models/coco-ssd": "^2.2.3"
}
}
```
**Bundle sizes:**
- OpenCV.js: ~500KB (lazy-loaded)
- TensorFlow.js: ~2MB (lazy-loaded)
- COCO-SSD model: ~2MB (lazy-loaded, cached after first load)
- **Total: ~4.5MB** (acceptable for modern networks)
---
## File Structure
```
web/src/reader/comic/
├── panel-detection.service.ts [NEW] - Main detection service with fallback chain
├── panel-detection.opencv.ts [NEW] - OpenCV edge detection
├── panel-detection.ml.ts [NEW] - COCO-SSD ML detection
├── panel-detector.ts [MODIFY] - Add export for grid detection
├── panel-editor.ts [MODIFY] - Add re-detect, connect to service
├── page-cache.ts [OPTIONAL] - On-demand detection
├── background-color.ts [KEEP]
├── chapter-markers.ts [KEEP]
├── page-order.ts [KEEP]
├── page-scrubber.ts [KEEP]
└── panel-gap.ts [KEEP]
```
---
## Implementation
### 1. Panel Detection Service (`panel-detection.service.ts`)
Create this file in `web/src/reader/comic/`:
```typescript
// Main panel detection service with fallback chain
// Priority: OpenCV → ML → Grid → Manual Editor
interface DetectionResult {
panels: Panel[];
method: "opencv" | "ml" | "grid" | "manual";
confidence: number;
}
interface Panel {
id: string;
x: number;
y: number;
width: number;
height: number;
reading_order: number;
}
async function detectPanels(
imageData: ImageData,
allowManual: boolean = true
): Promise<DetectionResult> {
// Tier 1: OpenCV Edge Detection
try {
const panels = await detectPanelsOpenCV(imageData);
if (validatePanels(panels, imageData)) {
return { panels, method: "opencv", confidence: 0.85 };
}
} catch (e) {
console.warn("OpenCV detection failed:", e);
}
// Tier 2: ML Detection (COCO-SSD)
try {
const panels = await detectPanelsML(imageData);
if (validatePanels(panels, imageData)) {
return { panels, method: "ml", confidence: 0.9 };
}
} catch (e) {
console.warn("ML detection failed:", e);
}
// Tier 3: Grid Detection (baseline)
const panels = detectPanelsGrid(imageData);
return { panels, method: "grid", confidence: 0.5 };
}
function validatePanels(panels: Panel[], imageData: ImageData): boolean {
// Must have at least 1 panel
if (panels.length === 0) return false;
// Should not have too many panels (probably noise)
if (panels.length > 30) return false;
// Panels should cover reasonable area (not all empty space)
let totalArea = panels.reduce((sum, p) => sum + (p.width * p.height), 0);
if (totalArea < 10 || totalArea > 100) return false;
return true;
}
// Import detection methods from other files
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]>;
async function detectPanelsML(imageData: ImageData): Promise<Panel[]>;
function detectPanelsGrid(imageData: ImageData, config?: { rows: number; cols: number }): Panel[];
export { detectPanels, DetectionResult, Panel };
```
---
### 2. OpenCV Detection (`panel-detection.opencv.ts`)
Create this file in `web/src/reader/comic/`:
```typescript
// OpenCV.js-based edge detection for panel boundaries
interface Panel {
id: string;
x: number;
y: number;
width: number;
height: number;
reading_order: number;
}
let openCVLoaded = false;
async function loadOpenCV(): Promise<void> {
if (openCVLoaded) return;
// OpenCV.js loads asynchronously and registers globally
await import("@techstark/opencv-js");
// Wait for OpenCV to be ready
return new Promise<void>((resolve) => {
const check = () => {
if ((window as any).cv && (window as any).cv.Mat) {
openCVLoaded = true;
resolve();
} else {
setTimeout(check, 50);
}
};
check();
});
}
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]> {
await loadOpenCV();
const cv = (window as any).cv;
// Create matrices from ImageData
const src = cv.matFromImageData(imageData);
const gray = new cv.Mat();
const blurred = new cv.Mat();
const edges = new cv.Mat();
const contours = new cv.Mat();
const hierarchy = new cv.Mat();
try {
// Convert to grayscale
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
// Apply Gaussian blur to reduce noise
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
// Detect edges using Canny
cv.Canny(blurred, edges, 50, 150, 3, false);
// Find contours
cv.findContours(
edges,
contours,
hierarchy,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE
);
// Convert contours to panels
const panels: Panel[] = [];
const imgWidth = imageData.width;
const imgHeight = imageData.height;
for (let i = 0; i < contours.size(); i++) {
const rect = cv.boundingRect(contours.get(i));
const aspectRatio = rect.width / rect.height;
// Filter: reject very small or very thin contours
const minSize = Math.min(imgWidth, imgHeight) * 0.05;
if (rect.width < minSize || rect.height < minSize) continue;
if (aspectRatio < 0.1 || aspectRatio > 10) continue;
panels.push({
id: `opencv-panel-${i}`,
x: (rect.x / imgWidth) * 100,
y: (rect.y / imgHeight) * 100,
width: (rect.width / imgWidth) * 100,
height: (rect.height / imgHeight) * 100,
reading_order: i,
});
}
// Sort panels by reading order (top-left to bottom-right)
panels.sort((a, b) => {
const rowA = Math.floor(a.y / 25);
const rowB = Math.floor(b.y / 25);
if (rowA !== rowB) return rowA - rowB;
return a.x - b.x;
});
// Reassign reading order after sorting
panels.forEach((p, i) => (p.reading_order = i));
return panels;
} finally {
// Clean up OpenCV matrices
src.delete();
gray.delete();
blurred.delete();
edges.delete();
contours.delete();
hierarchy.delete();
}
}
export { detectPanelsOpenCV, loadOpenCV };
```
---
### 3. ML Detection (`panel-detection.ml.ts`)
Create this file in `web/src/reader/comic/`:
```typescript
// ML-based panel detection using COCO-SSD pre-trained model
interface Panel {
id: string;
x: number;
y: number;
width: number;
height: number;
reading_order: number;
}
let model: any = null;
let tfLoaded = false;
async function loadTF(): Promise<void> {
if (tfLoaded) return;
// Load TensorFlow.js
await import("@tensorflow/tfjs");
tfLoaded = true;
}
async function loadModel(): Promise<void> {
if (model) return;
await loadTF();
// Load COCO-SSD model (pre-trained on millions of images)
const cocoSsd = await import("@tensorflow-models/coco-ssd");
model = await cocoSsd.load({
base: "lite_mobilenet_v2", // Smaller, faster model
});
}
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
await loadModel();
// Create HTMLCanvasElement to run model inference
const canvas = document.createElement("canvas");
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext("2d")!;
ctx.putImageData(imageData, 0, 0);
// Run COCO-SSD model
const predictions = await model.detect(canvas);
// Filter predictions to find rectangular regions (panels)
// COCO-SSD detects common objects, we look for rectangular ones
const panels: Panel[] = [];
const imgWidth = imageData.width;
const imgHeight = imageData.height;
for (let i = 0; i < predictions.length; i++) {
const pred = predictions[i];
// COCO-SSD detects "book" and similar objects
// We filter for reasonable panel-like detections
const [x, y, w, h] = pred.bbox;
const aspectRatio = w / h;
const isRectangular =
aspectRatio > 0.3 && // Not too tall/thin
aspectRatio < 5 && // Not too wide
w > imgWidth * 0.05 && // Not too small
h > imgHeight * 0.05;
if (isRectangular) {
panels.push({
id: `ml-panel-${i}`,
x: (x / imgWidth) * 100,
y: (y / imgHeight) * 100,
width: (w / imgWidth) * 100,
height: (h / imgHeight) * 100,
reading_order: i,
});
}
}
// Sort panels by reading order
panels.sort((a, b) => {
const rowA = Math.floor(a.y / 25);
const rowB = Math.floor(b.y / 25);
if (rowA !== rowB) return rowA - rowB;
return a.x - b.x;
});
panels.forEach((p, i) => (p.reading_order = i));
return panels;
}
export { detectPanelsML, loadModel };
```
---
### 4. Grid Detection (`panel-detector.ts` - Update)
Modify the existing `panel-detector.ts` to add the export at the end:
```typescript
// Grid-based panel detection (fast, lightweight)
// Keep as final fallback
interface Panel {
id: string;
x: number;
y: number;
width: number;
height: number;
reading_order: number;
}
interface GridConfig {
rows: number;
cols: number;
}
function detectPanelsGrid(
imageData: ImageData,
config: GridConfig = { rows: 3, cols: 3 },
): Panel[] {
const panels: Panel[] = [];
const cellWidth = imageData.width / config.cols;
const cellHeight = imageData.height / config.rows;
for (let y = 0; y < config.rows; y++) {
for (let x = 0; x < config.cols; x++) {
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
if (!isEmpty(cell)) {
panels.push({
id: `panel-${panels.length}`,
x: (x / config.cols) * 100,
y: (y / config.rows) * 100,
width: (1 / config.cols) * 100,
height: (1 / config.rows) * 100,
reading_order: panels.length,
});
}
}
}
return mergeAdjacentPanels(panels);
}
function isEmpty(cellData: ImageData): boolean {
let emptyPixels = 0;
const totalPixels = cellData.width * cellData.height;
const threshold = 0.95;
for (let i = 0; i < cellData.data.length; i += 4) {
const r = cellData.data[i];
const g = cellData.data[i + 1];
const b = cellData.data[i + 2];
const a = cellData.data[i + 3];
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
emptyPixels++;
}
}
return emptyPixels / totalPixels > threshold;
}
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
const merged: Panel[] = [];
const used = new Set<number>();
for (let i = 0; i < panels.length; i++) {
if (used.has(i)) continue;
let current = { ...panels[i] };
used.add(i);
for (let j = i + 1; j < panels.length; j++) {
if (used.has(j)) continue;
if (isAdjacent(current, panels[j])) {
current = mergePanels(current, panels[j]);
used.add(j);
}
}
merged.push(current);
}
return merged;
}
function extractCell(
imageData: ImageData,
gridX: number,
gridY: number,
cellWidth: number,
cellHeight: number,
): ImageData {
const startX = Math.floor(gridX * cellWidth);
const startY = Math.floor(gridY * cellHeight);
const width = Math.floor(cellWidth);
const height = Math.floor(cellHeight);
const cellData = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4;
const destIdx = (y * width + x) * 4;
cellData[destIdx] = imageData.data[srcIdx];
cellData[destIdx + 1] = imageData.data[srcIdx + 1];
cellData[destIdx + 2] = imageData.data[srcIdx + 2];
cellData[destIdx + 3] = imageData.data[srcIdx + 3];
}
}
return new ImageData(cellData, width, height);
}
function isAdjacent(p1: Panel, p2: Panel): boolean {
const tolerance = 5;
if (Math.abs(p1.y - p2.y) < tolerance && Math.abs(p1.height - p2.height) < tolerance) {
return Math.abs(p1.x + p1.width - p2.x) < tolerance || Math.abs(p2.x + p2.width - p1.x) < tolerance;
}
if (Math.abs(p1.x - p2.x) < tolerance && Math.abs(p1.width - p2.width) < tolerance) {
return Math.abs(p1.y + p1.height - p2.y) < tolerance || Math.abs(p2.y + p2.height - p1.y) < tolerance;
}
return false;
}
function mergePanels(p1: Panel, p2: Panel): Panel {
const minX = Math.min(p1.x, p2.x);
const minY = Math.min(p1.y, p2.y);
const maxX = Math.max(p1.x + p1.width, p2.x + p2.width);
const maxY = Math.max(p1.y + p1.height, p2.y + p2.height);
return {
id: p1.id,
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
reading_order: Math.min(p1.reading_order, p2.reading_order),
};
}
// ADD THIS EXPORT AT THE END OF THE FILE
export { detectPanelsGrid, isEmpty, mergeAdjacentPanels, extractCell, isAdjacent, mergePanels };
```
---
### 5. Panel Editor Updates (`panel-editor.ts`)
Modify the existing `panel-editor.ts` to add imports and re-detect function:
```typescript
// Manual panel editor for admins/power users
import { Alpine } from "../../alpine";
import { apiPut } from "../../api";
import { detectPanels, Panel } from "./panel-detection.service";
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
const mediaItemId = document.body.dataset.mediaItemId;
if (!mediaItemId) {
throw new Error("No mediaItemId found");
}
const token = localStorage.getItem("token");
const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`Failed to load page ${pageNumber}`);
}
const blob = await response.blob();
const img = new Image();
img.src = URL.createObjectURL(blob);
await new Promise<void>((resolve) => {
img.onload = () => resolve();
});
return img;
}
function getCurrentPageNumber(): number {
const Alpine = (window as any).Alpine;
if (Alpine) {
const readerEl = document.querySelector('[x-data="readerShell"]');
if (readerEl) {
const readerShell = Alpine.$data(readerEl);
if (readerShell?.currentPage) {
return readerShell.currentPage;
}
}
}
const content = document.getElementById("reader-content");
const pageFromDataset = content?.dataset.currentPage;
if (pageFromDataset) {
return parseInt(pageFromDataset, 10);
}
return 1;
}
function loadPage(pageNumber: number): void {
window.dispatchEvent(
new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }),
);
}
function openPanelEditor(pageNumber: number): void {
const modal = document.getElementById("panel-editor-modal");
modal?.classList.remove("hidden");
const canvas = document.getElementById("panel-editor-canvas") as HTMLCanvasElement;
const ctx = canvas?.getContext("2d");
loadImageForPage(pageNumber).then((image) => {
canvas!.width = image.width;
canvas!.height = image.height;
ctx?.drawImage(image, 0, 0);
enablePanelDrawing(canvas!);
});
}
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
let isDrawing = false;
let startX = 0;
let startY = 0;
canvas.addEventListener("mousedown", (e) => {
isDrawing = true;
startX = e.offsetX;
startY = e.offsetY;
});
canvas.addEventListener("mousemove", (e) => {
if (!isDrawing) return;
const ctx = canvas.getContext("2d");
// Clear and redraw to show selection rectangle
ctx?.clearRect(0, 0, canvas.width, canvas.height);
ctx?.drawImage(canvas, 0, 0);
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
});
canvas.addEventListener("mouseup", (e) => {
if (!isDrawing) return;
isDrawing = false;
const panel: Panel = {
id: `manual-${Date.now()}`,
x: (startX / canvas.width) * 100,
y: (startY / canvas.height) * 100,
width: ((e.offsetX - startX) / canvas.width) * 100,
height: ((e.offsetY - startY) / canvas.height) * 100,
reading_order: 0,
};
saveManualPanel(panel);
});
}
async function saveManualPanel(panel: Panel): Promise<void> {
const mediaItemId = document.body.dataset.mediaItemId;
const pageNumber = getCurrentPageNumber();
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
detection_method: "manual",
panels: [panel],
});
loadPage(pageNumber);
}
// Re-detect panels using detection service
async function reDetectPanels(pageNumber: number): Promise<Panel[]> {
const image = await loadImageForPage(pageNumber);
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = await detectPanels(imageData, true);
return result.panels;
}
// Alpine component
Alpine.data("panelEditor", () => ({
get isComicOrManga(): boolean {
const libraryType = document.body.dataset.mediaType;
return libraryType === "comic" || libraryType === "manga";
},
openPanelEditor(pageNumber: number) {
openPanelEditor(pageNumber);
},
async reDetectPanels(pageNumber: number) {
const panels = await reDetectPanels(pageNumber);
return panels;
}
}));
export { openPanelEditor, reDetectPanels };
```
---
### 6. Page Cache Integration (`page-cache.ts` - Optional)
Optional: Add on-demand panel detection to page-cache.ts:
```typescript
// Add this import at the top
import { detectPanels } from "./panel-detection.service";
// Add to PageCacheState interface
interface PageCacheState {
cache: Map<number, HTMLImageElement>;
loading: Set<number>;
maxAhead: number;
mediaItemId: string;
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
}
// Add this function
async function detectPagePanels(
state: PageCacheState,
pageNumber: number
): Promise<any[]> {
// Check if already detected
if (state.panelData?.has(pageNumber)) {
return state.panelData.get(pageNumber)!.panels;
}
// Get or create image
let image: HTMLImageElement;
if (state.cache.has(pageNumber)) {
image = state.cache.get(pageNumber)!;
} else {
image = await loadComicPage(state, pageNumber);
}
// Run detection on demand
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = await detectPanels(imageData, true);
if (!state.panelData) {
state.panelData = new Map();
}
state.panelData.set(pageNumber, result);
return result.panels;
}
// Export the new function
export { createPageCache, getCachedPage, loadComicPage, detectPagePanels };
```
---
## Implementation Order
1. **Add dependencies to `package.json`** and run `npm install`
2. **Create `panel-detection.service.ts`**
3. **Create `panel-detection.opencv.ts`**
4. **Create `panel-detection.ml.ts`**
5. **Update `panel-detector.ts`** - add export statement (one line at the end)
6. **Update `panel-editor.ts`** - add imports and re-detect function
7. **(Optional) Update `page-cache.ts`** - add on-demand detection
---
## Future Enhancements
1. **User feedback loop:** Store user corrections to improve detection
2. **Per-comic detection:** Different methods for different comic styles
3. **Batch detection:** Pre-detect pages in background
4. **Detection history:** Track which method works best per comic
5. **Panel preview:** Show detected panels before entering panel view
+379
View File
@@ -0,0 +1,379 @@
# Timezone Implementation Plan
## Overview
Add per-user timezone support with system-wide fallback (set via docker-compose), defaulting to UTC. The database already stores all timestamps as UTC via `TIMESTAMPTZ` columns, so this is primarily a display-layer feature.
**Display format:** MM-DD-YYYY HH:MM (US convention, no timezone abbreviation shown)
**Timezone selection:** Manual dropdown only (no browser auto-detect)
---
## Phase 1: Database Schema
**File:** `database/schema/schema.sql`
1. Add `timezone` column directly to the `users` table definition (line ~36):
```sql
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
theme VARCHAR(50) DEFAULT 'tokyo-night',
max_devices INTEGER DEFAULT 10,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
timezone VARCHAR(50) DEFAULT 'UTC'
);
```
> Note: The `timezone` column is already present at line 36 in the current schema. No change needed for this step.
1. Add `default_timezone` to the `system_settings` INSERT block (line ~49-52):
```sql
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
('default_timezone', 'UTC', 'System default timezone')
ON CONFLICT (setting_key) DO NOTHING;
```
1. Add index in the indexes section (after line ~460, with other user indexes):
```sql
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
```
1. Regenerate sqlc code:
```bash
cd internal/database && sqlc generate
```
---
## Phase 2: Database Queries
**File:** `internal/database/queries/queries.sql`
Add new queries:
```sql
-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
```
> Note: `UpdateSystemTimezone` is omitted because the existing `UpdateSystemSetting` query handles it by passing `'default_timezone'` as the key parameter.
Regenerate after adding queries:
```bash
cd internal/database && sqlc generate
```
---
## Phase 3: Template Utilities
**File:** `templates/utils.go`
Add timezone-aware time formatting helpers:
```go
package templates
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM
func FormatInTimezone(t time.Time, timezone string) string {
if t.IsZero() {
return ""
}
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
return t.In(loc).Format("01-02-2006 03:04 PM")
}
// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone
func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string {
if !t.Valid {
return ""
}
return FormatInTimezone(t.Time, timezone)
}
```
---
## Phase 4: User Context Update
**File:** `templates/types.go`
Add `Timezone` field to the `User` struct:
```go
type User struct {
ID string
Email string
Username string
Role string
Theme string
FirstName string
LastName string
CreatedAt time.Time
Token string
Timezone string
}
```
**File:** `internal/router/helpers.go`
Update `getTemplateUserWithTheme()` to include timezone:
```go
userTimezone := "UTC"
if userDB.Timezone.Valid {
userTimezone = userDB.Timezone.String
}
return templates.User{
// ... existing fields ...
Timezone: userTimezone,
}
```
---
## Phase 5: Handlers
**File:** `internal/handlers/auth.go`
Update `UpdateProfileRequest` struct:
```go
type UpdateProfileRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
}
```
Add timezone update logic in `UpdateProfile()`:
```go
if req.Timezone != "" {
if _, err := time.LoadLocation(req.Timezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid timezone",
})
}
err := h.db.UpdateUserTimezone(ctx, database.UpdateUserTimezoneParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
})
if err != nil {
return err
}
}
```
**File:** `internal/handlers/system_settings.go`
Add timezone settings handler:
```go
type UpdateTimezoneSettingsRequest struct {
DefaultTimezone string `json:"default_timezone" validate:"required"`
}
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
var req UpdateTimezoneSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
}
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: req.DefaultTimezone,
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
```
---
## Phase 6: User Profile UI
**File:** `templates/profile_form.templ`
Add timezone dropdown after the theme field:
```templ
<div class="form-group">
<label for="timezone">Timezone</label>
<select name="timezone" id="timezone" class="form-select">
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (Coordinated Universal Time)</option>
<option value="America/New_York" selected?={ user.Timezone == "America/New_York" }>Eastern Time</option>
<option value="America/Chicago" selected?={ user.Timezone == "America/Chicago" }>Central Time</option>
<option value="America/Denver" selected?={ user.Timezone == "America/Denver" }>Mountain Time</option>
<option value="America/Los_Angeles" selected?={ user.Timezone == "America/Los_Angeles" }>Pacific Time</option>
<option value="America/Phoenix" selected?={ user.Timezone == "America/Phoenix" }>Mountain Time (no DST)</option>
<option value="America/Anchorage" selected?={ user.Timezone == "America/Anchorage" }>Alaska Time</option>
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii Time</option>
</select>
</div>
```
Include timezone in the HTMX form submission payload.
---
## Phase 7: Admin Settings UI
**File:** `templates/admin_settings.templ`
Add system default timezone setting:
```templ
<div class="setting-group">
<h3>System Defaults</h3>
<label for="default_timezone">Default Timezone</label>
<select name="default_timezone" id="default_timezone">
<option value="UTC">UTC (Coordinated Universal Time)</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="America/Phoenix">Mountain Time (no DST)</option>
<option value="America/Anchorage">Alaska Time</option>
<option value="Pacific/Honolulu">Hawaii Time</option>
</select>
</div>
```
---
## Phase 8: Template Time Display Updates
### Files to update
| Template | Line(s) | Field(s) |
| ------------------------------------ | -------------- | ----------------------------------- |
| `templates/book_detail.templ` | ~253, ~282 | `LastReadAt`, `DatePublished` |
| `templates/book_detail_modals.templ` | ~68, ~113 | `Timestamp`, `LastReadAt` |
| `templates/devices.templ` | ~83, ~91, ~174 | `LastSync`, `LastSeen`, `ExpiresAt` |
| `templates/conflicts.templ` | ~114 | `CreatedAt` |
| `templates/admin_users.templ` | ~89 | `CreatedAt` |
| `templates/queue.templ` | ~138 | `CreatedAt` |
### Change pattern
```templ
<!-- Before -->
{ book.ReadingProgress.LastReadAt.Time.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
```
For `time.Time` fields:
```templ
<!-- Before -->
{ device.LastSync.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatInTimezone(device.LastSync, user.Timezone) }
```
---
## Phase 9: Docker Configuration
**File:** `docker-compose.yml`
```yaml
services:
server:
environment:
- TZ=UTC
```
**File:** `.env.example`
```
# System default timezone (fallback if not set in DB)
TZ=UTC
```
---
## Files Modified Summary
| File | Change |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `database/schema/schema.sql` | Add timezone column to users, system_setting row |
| `internal/database/queries/queries.sql` | Add UpdateUserTimezone, GetSystemTimezone (reuses existing UpdateSystemSetting) |
| `templates/utils.go` | Add FormatInTimezone, FormatTimestamptzInTimezone |
| `templates/types.go` | Add Timezone field to User struct |
| `internal/router/helpers.go` | Pass timezone to template User |
| `internal/handlers/auth.go` | Handle timezone updates in UpdateProfile |
| `internal/handlers/system_settings.go` | Add timezone settings handler |
| `templates/profile_form.templ` | Add timezone dropdown |
| `templates/admin_settings.templ` | Add default timezone setting |
| `templates/book_detail.templ` | Update time displays |
| `templates/book_detail_modals.templ` | Update time displays |
| `templates/devices.templ` | Update time displays |
| `templates/conflicts.templ` | Update time displays |
| `templates/admin_users.templ` | Update time displays |
| `templates/queue.templ` | Update time displays |
| `docker-compose.yml` | Add TZ env var |
| `.env.example` | Add TZ example |
---
## Testing Checklist
- [ ] Create user, set timezone to Eastern, verify times display in MM-DD-YYYY HH:MM format
- [ ] Create user, set timezone to Pacific, verify different offset
- [ ] Test system default timezone fallback for users with no timezone set
- [ ] Verify invalid timezone values are rejected by the API
- [ ] Verify existing users (no timezone set) fall back to system default
- [ ] Verify all templates show consistent MM-DD-YYYY HH:MM format
- [ ] Run `make test-integration` to verify no regressions
---
## Deployment Steps
1. Update `database/schema/schema.sql` with new column and settings
2. Regenerate sqlc: `cd internal/database && sqlc generate`
3. Apply schema changes (restart database container with `make rebuild-force-db`)
4. Deploy backend code changes
5. Verify with existing data
+22 -22
View File
@@ -2,20 +2,20 @@ name: Bookhoard
variables:
- name: base_url
value: http://localhost:8765
- name: media_item_id
value: 8bd13107-e1e5-4357-b893-bfc77f1e087c
- name: fake_book_id
value: 123e4567-e89b-12d3-a456-426614174000
- name: user_id
value: c51118f0-31fc-4c32-827d-517d6599bf21
- name: highlight_id
value: 660f9501-f29b-51d4-b716-446655440001
- name: note_id
value: 7710a602-g29b-61d4-c716-446655440002
- name: ebook_library_id
value: 0df0ea2b-1965-494a-a0a3-cce8536d5f28
- name: job_id
value: 709e0d8e-b866-496c-b260-a59ab8e2014c
- secret: true
name: media_item_id
- secret: true
name: fake_book_id
- secret: true
name: user_id
- secret: true
name: highlight_id
- secret: true
name: note_id
- secret: true
name: ebook_library_id
- secret: true
name: job_id
- name: rating
value: "5"
- name: is_visible
@@ -32,11 +32,11 @@ variables:
name: kobo_device_token
- secret: true
name: other_device_id
- name: collection_id
value: 412c03c3-0843-4bd4-b764-cc9457bb9df2
- name: library_folder_id
value: da1f9d91-0c4c-40cc-a050-86f795dfc967
- name: comic_library_id
value: 7b6d0c8c-73dc-4346-804c-5f6e11c3d658
- name: manga_library_id
value: b134d16d-9668-4867-b50e-8350037f1a4a
- secret: true
name: collection_id
- secret: true
name: library_folder_id
- secret: true
name: comic_library_id
- secret: true
name: manga_library_id
+11 -3
View File
@@ -62,32 +62,38 @@ func main() {
// Create WebSocket connection manager
connManager := sync.NewConnectionManager()
// Create sync queue processor
progressService := sync.NewProgressService(queries, connManager)
queueProcessor := sync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService)
// Create library service
libraryService := services.NewLibraryService(queries)
// Sync Go AllowedExtensions into DB so API clients see correct extensions
libraryService.SyncAllowedExtensions(context.Background())
// Create worker for background tasks
worker := services.NewWorker(3, connManager)
services.WorkerInstance = worker
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
koreaderHandler.SetProgressService(progressService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager)
analyticsHandler := handlers.NewAnalyticsHandler(queries)
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
// Create conversion service for EPUB→KEPUB conversion
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
// NEW: Create refactored handlers
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
mediaHandler.SetProgressService(progressService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker)
@@ -147,12 +153,14 @@ func main() {
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
Worker: worker,
SystemSettingsHandler: systemSettingsHandler,
SidecarHandler: sidecarHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
DeviceAuthMiddleware: deviceAuthMiddleware,
JobsHandler: jobsHandler,
LoginTracker: loginAttemptTracker,
+91 -39
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/handlers"
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"time"
@@ -21,7 +22,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -32,12 +35,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ReadingStatsResponse
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.TotalBooksRead, 0)
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
@@ -45,15 +51,17 @@ func TestAnalyticsReadingStats(t *testing.T) {
})
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
startDate := time.Now().AddDate(0, -2, 0).Format("2006-01-02")
endDate := time.Now().Format("2006-01-02")
startDate := time.Now().AddDate(0, -2, 0).Format("01-02-2006")
endDate := time.Now().Format("01-02-2006")
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
@@ -64,7 +72,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -75,7 +85,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -86,12 +98,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Should return zero values for empty history
assert.Equal(t, 0.0, result["total_books_read"])
@@ -108,7 +123,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -119,19 +136,19 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.DeviceUsageResponse
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotNil(t, result.Devices)
assert.Equal(t, 0, len(result.Devices))
})
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
// First create a device
// First, create a device
deviceReq := map[string]interface{}{
"device_name": "Test Kobo",
"device_type": "kobo",
@@ -144,7 +161,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
resp, err := client.Do(deviceReqHTTP)
require.NoError(t, err)
resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Now get device usage
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
@@ -152,12 +171,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
devices, ok := result["devices"].([]interface{})
assert.True(t, ok)
@@ -171,12 +193,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "devices")
@@ -203,7 +228,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -214,12 +241,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.PopularBooksResponse
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotNil(t, result.Books)
// Default limit is 10, but may be fewer if no reading history
@@ -232,12 +262,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
books := result["books"].([]interface{})
assert.True(t, len(books) <= 5)
@@ -249,20 +282,23 @@ func TestAnalyticsPopularBooks(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should default to 10 on invalid limit
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
books := result["books"].([]interface{})
assert.True(t, len(books) <= 10)
})
t.Run("GetPopularBooks_ResponseStructure", func(t *testing.T) {
// First create a book and some reading history
// First, create a book and some reading history
bookID := createTestMediaItemID(t, setup)
// Create reading history for the book
@@ -280,7 +316,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
resp, err := client.Do(historyHTTP)
require.NoError(t, err)
resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Now get popular books
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
@@ -288,12 +326,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
books := result["books"].([]interface{})
@@ -315,12 +356,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
books := result["books"].([]interface{})
// Should return empty array if no reading history
@@ -334,21 +378,24 @@ func TestAnalyticsEdgeCases(t *testing.T) {
client := &http.Client{}
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
startDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
endDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
startDate := time.Now().AddDate(0, 0, 7).Format("01-02-2006")
endDate := time.Now().AddDate(0, 0, 14).Format("01-02-2006")
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should succeed but return empty stats
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0.0, result["total_books_read"])
})
@@ -359,13 +406,16 @@ func TestAnalyticsEdgeCases(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle limit=0 gracefully
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
books := result["books"].([]interface{})
assert.Equal(t, 0, len(books))
@@ -377,7 +427,9 @@ func TestAnalyticsEdgeCases(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle large limit
assert.Equal(t, http.StatusOK, resp.StatusCode)
+104 -38
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
@@ -27,7 +28,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -47,12 +50,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "matches")
assert.Contains(t, result, "action")
@@ -67,7 +73,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -85,12 +93,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
var matches []interface{}
if matchesIf, ok := result["matches"]; ok && matchesIf != nil {
@@ -124,7 +135,9 @@ func TestBookMatchingBulkLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -142,12 +155,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0.0, result["total"])
assert.Equal(t, 0.0, result["successful"])
@@ -175,12 +191,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
@@ -221,12 +240,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 3.0, result["total"])
results := result["results"].([]interface{})
@@ -251,7 +273,9 @@ func TestBookMatchingAutoLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -267,12 +291,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "auto_linked")
assert.Contains(t, result, "results")
@@ -292,12 +319,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "auto_linked")
})
@@ -315,12 +345,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Should succeed even with no books to link
assert.Contains(t, result, "auto_linked")
@@ -338,7 +371,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -350,7 +385,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -363,7 +400,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
@@ -378,7 +417,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Even when book not found, we expect 404
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
@@ -396,7 +437,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -409,12 +452,15 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "device_id")
assert.Contains(t, result, "aliases")
@@ -439,7 +485,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -462,7 +510,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -485,7 +535,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -505,7 +557,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -519,7 +573,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -535,7 +591,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -547,12 +605,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "matches")
assert.Contains(t, result, "action")
@@ -565,7 +626,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -577,12 +640,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "matches")
})
+71 -27
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
@@ -44,7 +45,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -61,7 +64,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -85,12 +90,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
@@ -122,10 +130,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(collectionHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var collectionResult map[string]interface{}
json.NewDecoder(resp.Body).Decode(&collectionResult)
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
require.NoError(t, err)
collectionID := collectionResult["id"].(string)
// Now try to add invalid book IDs
@@ -145,12 +156,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err = client.Do(addHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
results := result["results"].([]interface{})
firstResult := results[0].(map[string]interface{})
@@ -171,10 +185,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(collectionHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var collectionResult map[string]interface{}
json.NewDecoder(resp.Body).Decode(&collectionResult)
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
require.NoError(t, err)
collectionID := collectionResult["id"].(string)
// Create a book
@@ -197,12 +214,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err = client.Do(addHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
@@ -228,10 +248,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(collectionHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var collectionResult map[string]interface{}
json.NewDecoder(resp.Body).Decode(&collectionResult)
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
require.NoError(t, err)
collectionID := collectionResult["id"].(string)
// Create multiple books
@@ -256,12 +279,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err = client.Do(addHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 3.0, result["total"])
assert.True(t, result["added"].(float64) > 0)
@@ -281,10 +307,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(collectionHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var collectionResult1 map[string]interface{}
json.NewDecoder(resp.Body).Decode(&collectionResult1)
err = json.NewDecoder(resp.Body).Decode(&collectionResult1)
require.NoError(t, err)
collectionID1 := collectionResult1["id"].(string)
collectionReq2 := map[string]interface{}{
@@ -299,10 +328,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err = client.Do(collectionHTTP2)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var collectionResult2 map[string]interface{}
json.NewDecoder(resp.Body).Decode(&collectionResult2)
err = json.NewDecoder(resp.Body).Decode(&collectionResult2)
require.NoError(t, err)
collectionID2 := collectionResult2["id"].(string)
// Create books
@@ -330,12 +362,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err = client.Do(addHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Equal(t, 3.0, result["total"])
@@ -355,10 +390,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(collectionHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var collectionResult map[string]interface{}
json.NewDecoder(resp.Body).Decode(&collectionResult)
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
require.NoError(t, err)
collectionID := collectionResult["id"].(string)
// Create a book
@@ -381,7 +419,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err = client.Do(addHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Try to add same book again - create new request with fresh body
addBody2, _ := json.Marshal(addReq)
@@ -391,7 +431,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp2, err := client.Do(addHTTP2)
require.NoError(t, err)
defer resp2.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
// Should handle duplicate gracefully (either succeed or return error)
assert.Equal(t, http.StatusOK, resp2.StatusCode)
@@ -405,7 +447,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
+18 -9
View File
@@ -112,7 +112,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Equal(t, 0, len(items), "Empty rules should return no matched items")
@@ -139,7 +140,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books")
@@ -171,7 +173,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Greater(t, len(items), 0, "Should return books matching the genre rule")
@@ -203,7 +206,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Greater(t, len(items), 0, "Should return books from rules and manual selection")
@@ -233,7 +237,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter")
@@ -259,7 +264,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max")
@@ -285,7 +291,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items")
@@ -312,7 +319,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included")
@@ -340,7 +348,8 @@ func TestPreviewCollection(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]interface{}
json.NewDecoder(rec.Body).Decode(&result)
err := json.NewDecoder(rec.Body).Decode(&result)
require.NoError(t, err)
items := result["items"].([]interface{})
assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items")
+72 -24
View File
@@ -132,7 +132,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -152,7 +154,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -169,7 +173,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -190,7 +196,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -210,7 +218,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -227,7 +237,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -244,7 +256,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -262,7 +276,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -280,7 +296,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -298,7 +316,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -316,7 +336,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -334,7 +356,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -353,7 +377,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -371,7 +397,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -389,7 +417,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -439,7 +469,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -469,7 +501,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -500,7 +534,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -526,7 +562,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -567,7 +605,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -594,7 +634,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/media/"+mediaUUID, nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should require authentication")
})
@@ -610,7 +652,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -629,7 +673,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
@@ -719,7 +765,9 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body := readBody(resp)
+550 -257
View File
@@ -1,289 +1,582 @@
package main
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bytes"
"context"
"encoding/json"
"net/http/httptest"
"io"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConflictDetection_TriggeringConditions(t *testing.T) {
t.Run("conflict detected when different devices sync within 5 minutes", func(t *testing.T) {
conflictData := map[string]map[string]interface{}{
"koreader": {
"source": "koreader",
"timestamp": "2026-01-30T20:10:00Z",
"data": map[string]interface{}{
"percentage": 0.45,
"epubcfi": "epubcfi(/6/4/2:15)",
"chapter": 3,
},
},
"kobo": {
"source": "kobo",
"timestamp": "2026-01-30T20:05:00Z",
"data": map[string]interface{}{
"percentage": 0.42,
"page": 89,
},
},
}
body, err := json.Marshal(conflictData)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
assert.Equal(t, "POST", req.Method)
assert.Contains(t, string(body), "koreader")
assert.Contains(t, string(body), "kobo")
})
t.Run("no conflict when progress difference is less than 1%", func(t *testing.T) {
progressData := map[string]interface{}{
"percentage": 0.45,
}
existingProgress := map[string]interface{}{
"percentage": 0.451,
}
diff := progressData["percentage"].(float64) - existingProgress["percentage"].(float64)
if diff < 0 {
diff = -diff
}
assert.Less(t, diff, 0.01, "Should not trigger conflict for small differences")
})
t.Run("no conflict when sync timestamps are more than 5 minutes apart", func(t *testing.T) {
timestamp1 := "2026-01-30T20:00:00Z"
timestamp2 := "2026-01-30T20:10:00Z"
var conflictDetected bool
if timestamp2 > timestamp1 {
conflictDetected = false
}
assert.False(t, conflictDetected, "Should not trigger conflict for old syncs")
})
type conflictTestEnv struct {
setup *TestServerSetup
mediaID string
userID pgtype.UUID
mediaPGID pgtype.UUID
}
func TestConflictResolution_ChoosingWinner(t *testing.T) {
t.Run("resolve conflict by choosing koreader source", func(t *testing.T) {
conflictID := uuid.New()
func setupConflictTest(t *testing.T) *conflictTestEnv {
t.Helper()
reqBody := map[string]interface{}{
"winner": "koreader",
"manual_data": nil,
"apply_to_all_future_conflicts": false,
"reason": "More recent progress",
}
setup := setupTestServer(t)
mediaID := createTestMediaItemID(t, setup)
body, err := json.Marshal(reqBody)
assert.NoError(t, err)
ctx := context.Background()
user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mediaUUID, err := uuid.Parse(mediaID)
require.NoError(t, err)
assert.Equal(t, "POST", req.Method)
assert.Contains(t, req.URL.Path, conflictID.String())
assert.Contains(t, string(body), "koreader")
return &conflictTestEnv{
setup: setup,
mediaID: mediaID,
userID: user.ID,
mediaPGID: pgtype.UUID{Bytes: [16]byte(mediaUUID), Valid: true},
}
}
func createTestConflict(t *testing.T, env *conflictTestEnv, conflictData map[string]interface{}) database.SyncConflicts {
t.Helper()
ctx := context.Background()
dataJSON, err := json.Marshal(conflictData)
require.NoError(t, err)
conflict, err := env.setup.DB.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
MediaItemID: env.mediaPGID,
UserID: env.userID,
ConflictType: "progress",
ConflictData: dataJSON,
})
require.NoError(t, err)
t.Run("resolve conflict with manual merge data", func(t *testing.T) {
conflictID := uuid.New()
return conflict
}
manualData := map[string]interface{}{
func makeConflictData(koreaderPct, koboPct float64) map[string]interface{} {
return map[string]interface{}{
"koreader": map[string]interface{}{
"source": "koreader",
"timestamp": time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
"data": map[string]interface{}{
"percentage": koreaderPct,
},
},
"kobo": map[string]interface{}{
"source": "kobo",
"timestamp": time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
"data": map[string]interface{}{
"percentage": koboPct,
},
},
}
}
func TestConflictList_Empty(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictListResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Total)
assert.Empty(t, result.Conflicts)
}
func TestConflictList_WithConflicts(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
createTestConflict(t, env, makeConflictData(0.45, 0.42))
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts?status=all", nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictListResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Total, 1)
require.NotEmpty(t, result.Conflicts)
conflict := result.Conflicts[0]
assert.Equal(t, "progress", conflict.ConflictType)
assert.Equal(t, "unresolved", conflict.ResolutionStatus)
assert.Contains(t, conflict.ConflictData, "koreader")
assert.Contains(t, conflict.ConflictData, "kobo")
}
func TestConflictList_UnresolvedCount(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
createTestConflict(t, env, makeConflictData(0.45, 0.42))
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts", nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var result handlers.ConflictListResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Unresolved, 1)
}
func TestConflictGet_ByID(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var detail handlers.ConflictDetailResponse
err = json.NewDecoder(resp.Body).Decode(&detail)
require.NoError(t, err)
assert.Equal(t, conflictID, detail.ID)
assert.Equal(t, env.mediaID, detail.MediaItemID)
assert.Equal(t, "progress", detail.ConflictType)
assert.Contains(t, detail.ConflictData, "koreader")
assert.Contains(t, detail.ConflictData, "kobo")
}
func TestConflictGet_NotFound(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestConflictGet_InvalidID(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/not-a-uuid", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestConflictResolve_ByKOReader(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.75, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "koreader",
"manual_data": nil,
"apply_to_all_future_conflicts": false,
"reason": "More recent progress",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.True(t, result.ConflictResolved)
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
}
func TestConflictResolve_ByKobo(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.30, 0.75))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "kobo",
"apply_to_all_future_conflicts": false,
"reason": "Higher progress",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.True(t, result.ConflictResolved)
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
}
func TestConflictResolve_WithManualData(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "manual",
"manual_data": map[string]interface{}{
"percentage": 0.43,
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 3,
"page": 90,
}
},
"apply_to_all_future_conflicts": false,
"reason": "Custom merged position",
}
body, _ := json.Marshal(resolveReq)
reqBody := map[string]interface{}{
"winner": "manual",
"manual_data": manualData,
"apply_to_all_future_conflicts": false,
"reason": "Custom merged position",
}
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
body, err := json.Marshal(reqBody)
assert.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.True(t, result.ConflictResolved)
}
func TestConflictResolve_ManualWithoutData(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "manual",
"manual_data": nil,
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestConflictResolve_AlreadyResolved(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "koreader",
"reason": "First resolution",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp2, err := client.Do(req2)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
assert.Equal(t, http.StatusBadRequest, resp2.StatusCode)
}
func TestConflictResolve_InvalidWinner(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "nonexistent_source",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestConflictResolve_NotFound(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
resolveReq := map[string]interface{}{
"winner": "koreader",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestConflictDelete(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req, _ := http.NewRequest("DELETE", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
req2, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp2, err := client.Do(req2)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestConflictDelete_NotFound(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestConflictDismissAllResolved(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "koreader",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/dismiss-all", nil)
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp2, err := client.Do(req2)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp2.Body).Decode(&result)
require.NoError(t, err)
deleted, ok := result["deleted"].(float64)
assert.True(t, ok)
assert.GreaterOrEqual(t, int(deleted), 1)
}
func TestConflictEndpoints_RequireAuth(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
t.Run("list conflicts requires auth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("get conflict requires auth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("resolve conflict requires auth", func(t *testing.T) {
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
assert.Contains(t, string(body), "manual")
assert.Contains(t, string(body), "0.43")
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("error when winner is manual but no manual_data provided", func(t *testing.T) {
conflictID := uuid.New()
t.Run("delete conflict requires auth", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
reqBody := map[string]interface{}{
"winner": "manual",
"manual_data": nil,
"apply_to_all_future_conflicts": false,
"reason": "Test",
}
body, err := json.Marshal(reqBody)
assert.NoError(t, err)
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
assert.Contains(t, string(body), "manual")
})
}
func TestConflictListing_Filtering(t *testing.T) {
t.Run("list only unresolved conflicts", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/conflicts?status=unresolved", nil)
assert.Equal(t, "GET", req.Method)
assert.Contains(t, req.URL.Query().Get("status"), "unresolved")
})
t.Run("list all conflicts regardless of status", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/conflicts?status=all", nil)
assert.Equal(t, "GET", req.Method)
assert.Contains(t, req.URL.Query().Get("status"), "all")
})
t.Run("list only resolved conflicts", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/conflicts?status=user_resolved", nil)
assert.Equal(t, "GET", req.Method)
assert.Contains(t, req.URL.Query().Get("status"), "user_resolved")
})
}
func TestConflictResponse_Structure(t *testing.T) {
t.Run("conflict detail response includes all required fields", func(t *testing.T) {
conflictResponse := map[string]interface{}{
"id": "conflict-uuid-123",
"media_item_id": "book-uuid-456",
"media_item_title": "Test Book Title",
"conflict_type": "progress",
"resolution_status": "unresolved",
"created_at": "2026-01-30T20:10:00Z",
"conflict_data": map[string]interface{}{
"koreader": map[string]interface{}{
"source": "koreader",
"data": map[string]interface{}{
"percentage": 0.45,
},
},
"kobo": map[string]interface{}{
"source": "kobo",
"data": map[string]interface{}{
"percentage": 0.42,
},
},
},
}
body, err := json.Marshal(conflictResponse)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
assert.Contains(t, parsed, "id")
assert.Contains(t, parsed, "media_item_id")
assert.Contains(t, parsed, "conflict_data")
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "koreader")
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "kobo")
})
t.Run("conflict list response includes summary counts", func(t *testing.T) {
listResponse := map[string]interface{}{
"conflicts": []interface{}{
map[string]string{"id": "conflict-1", "resolution_status": "unresolved"},
map[string]string{"id": "conflict-2", "resolution_status": "unresolved"},
},
"total": 2,
"unresolved": 2,
}
body, err := json.Marshal(listResponse)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
assert.Equal(t, float64(2), parsed["total"])
assert.Equal(t, float64(2), parsed["unresolved"])
})
}
func TestConflictDeletion(t *testing.T) {
t.Run("delete single conflict by ID", func(t *testing.T) {
conflictID := uuid.New()
req := httptest.NewRequest("DELETE", "/api/conflicts/"+conflictID.String(), nil)
assert.Equal(t, "DELETE", req.Method)
assert.Contains(t, req.URL.Path, conflictID.String())
})
t.Run("dismiss all resolved conflicts", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/conflicts/dismiss-all", nil)
assert.Equal(t, "POST", req.Method)
assert.Contains(t, req.URL.Path, "dismiss-all")
})
}
func TestConflictNotification_WebSocketBroadcast(t *testing.T) {
t.Run("conflict detection notification", func(t *testing.T) {
notification := map[string]interface{}{
"type": "conflict",
"timestamp": "2026-01-30T20:10:00Z",
"data": map[string]interface{}{
"book_id": "book-uuid-123",
"notification_type": "detection",
"conflict_id": "",
},
}
body, err := json.Marshal(notification)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
data := parsed["data"].(map[string]interface{})
assert.Equal(t, "detection", data["notification_type"])
})
t.Run("conflict resolved notification", func(t *testing.T) {
conflictID := uuid.New()
notification := map[string]interface{}{
"type": "conflict",
"timestamp": "2026-01-30T20:15:00Z",
"data": map[string]interface{}{
"book_id": "book-uuid-123",
"notification_type": "resolved",
"conflict_id": conflictID.String(),
},
}
body, err := json.Marshal(notification)
assert.NoError(t, err)
var parsed map[string]interface{}
err = json.Unmarshal(body, &parsed)
assert.NoError(t, err)
data := parsed["data"].(map[string]interface{})
assert.Equal(t, "resolved", data["notification_type"])
assert.Equal(t, conflictID.String(), data["conflict_id"])
t.Run("dismiss-all requires auth", func(t *testing.T) {
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/dismiss-all", nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
+477 -384
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/handlers"
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
@@ -12,414 +13,506 @@ import (
"github.com/stretchr/testify/require"
)
// TestConflictsBulkOperations tests bulk conflict resolution operations
func TestConflictsBulkOperations(t *testing.T) {
func TestBulkResolve_MostRecentStrategy(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
id1 := uuid.UUID(conflict1.ID.Bytes).String()
id2 := uuid.UUID(conflict2.ID.Bytes).String()
req := handlers.BulkResolveRequest{
ConflictIDs: []string{id1, id2},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 2, result.Total)
assert.Equal(t, 2, result.Success)
assert.Equal(t, 0, result.Failed)
require.Len(t, result.Results, 2)
for _, r := range result.Results {
assert.Equal(t, "success", r.Status)
assert.Equal(t, "koreader", r.Winner)
}
}
func TestBulkResolve_HighestProgressStrategy(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflictDataHighKobo := makeConflictData(0.30, 0.90)
conflict := createTestConflict(t, env, conflictDataHighKobo)
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req := handlers.BulkResolveRequest{
ConflictIDs: []string{conflictID},
Strategy: "highest_progress",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 1, result.Total)
assert.Equal(t, 1, result.Success)
assert.Equal(t, 0, result.Failed)
assert.Equal(t, "kobo", result.Results[0].Winner)
}
func TestBulkResolve_ManualStrategy(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req := handlers.BulkResolveRequest{
ConflictIDs: []string{conflictID},
Strategy: "manual",
WinningSource: "koreader",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 1, result.Total)
assert.Equal(t, 1, result.Success)
assert.Equal(t, "koreader", result.Results[0].Winner)
}
func TestBulkResolve_ManualStrategy_WithoutWinner(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req := handlers.BulkResolveRequest{
ConflictIDs: []string{conflictID},
Strategy: "manual",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 1, result.Failed)
assert.Equal(t, "error", result.Results[0].Status)
assert.Contains(t, result.Results[0].Error, "winning_source")
}
func TestBulkResolve_ManualStrategy_InvalidWinner(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req := handlers.BulkResolveRequest{
ConflictIDs: []string{conflictID},
Strategy: "manual",
WinningSource: "nonexistent_device",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 1, result.Failed)
assert.Contains(t, result.Results[0].Error, "invalid winning source")
}
func TestBulkResolve_ConflictNotFound(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
t.Run("BulkResolveConflicts_WithoutAuth", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String()},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String()},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Run("BulkResolveConflicts_EmptyConflictIDs", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("BulkResolveConflicts_InvalidConflictID", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{"invalid-uuid"},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
json.NewDecoder(resp.Body).Decode(&result)
assert.NotEmpty(t, result.Results, "Should have results")
assert.Equal(t, 1, result.Total)
assert.Equal(t, 0, result.Success)
assert.Greater(t, result.Failed, 0)
firstResult := result.Results[0]
assert.Equal(t, "error", firstResult.Status)
})
t.Run("BulkResolveConflicts_InvalidStrategy", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String()},
Strategy: "invalid_strategy",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
// Bulk operations return 200 OK with individual error results
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
json.NewDecoder(resp.Body).Decode(&result)
assert.NotEmpty(t, result.Results, "Should have results")
assert.Greater(t, result.Total, 0)
assert.Greater(t, result.Failed, 0)
firstResult := result.Results[0]
assert.Equal(t, "error", firstResult.Status)
// The error will be "conflict not found" since we're using a random UUID
// The invalid strategy would be caught for valid conflict IDs
assert.Contains(t, firstResult.Error, "conflict")
})
t.Run("BulkResolveConflicts_MostRecentStrategy", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
json.NewDecoder(resp.Body).Decode(&result)
assert.NotEmpty(t, result.Results, "Should have results")
assert.Equal(t, 2, result.Total)
})
t.Run("BulkResolveConflicts_HighestProgressStrategy", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
Strategy: "highest_progress",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
json.NewDecoder(resp.Body).Decode(&result)
assert.NotEmpty(t, result.Results, "Should have results")
assert.Equal(t, 2, result.Total)
})
t.Run("BulkResolveConflicts_ManualStrategy_WithoutWinner", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String()},
Strategy: "manual",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
json.NewDecoder(resp.Body).Decode(&result)
assert.NotEmpty(t, result.Results, "Should have results")
})
t.Run("BulkResolveConflicts_ManualStrategy_WithWinner", func(t *testing.T) {
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String()},
Strategy: "manual",
WinningSource: "device",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("BulkResolveConflicts_InvalidRequestBody", func(t *testing.T) {
// Send invalid JSON
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
assert.Equal(t, 1, result.Total)
assert.Equal(t, 0, result.Success)
assert.Equal(t, 1, result.Failed)
assert.Contains(t, result.Results[0].Error, "conflict not found")
}
// TestConflictsBulkDismiss tests bulk dismiss operations
func TestConflictsBulkDismiss(t *testing.T) {
func TestBulkResolve_EmptyConflictIDs(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
t.Run("BulkDismissConflicts_WithoutAuth", func(t *testing.T) {
req := map[string]interface{}{
"conflict_ids": []string{uuid.New().String()},
}
body, _ := json.Marshal(req)
req := handlers.BulkResolveRequest{
ConflictIDs: []string{},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("BulkDismissConflicts_EmptyConflictIDs", func(t *testing.T) {
req := map[string]interface{}{
"conflict_ids": []string{},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("BulkDismissConflicts_InvalidConflictID", func(t *testing.T) {
req := map[string]interface{}{
"conflict_ids": []string{"invalid-uuid", uuid.New().String()},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
assert.Contains(t, result, "success")
assert.Contains(t, result, "failed")
results := result["results"].([]interface{})
firstResult := results[0].(map[string]interface{})
assert.Equal(t, "error", firstResult["status"])
})
t.Run("BulkDismissConflicts_MultipleConflicts", func(t *testing.T) {
req := map[string]interface{}{
"conflict_ids": []string{
uuid.New().String(),
uuid.New().String(),
uuid.New().String(),
},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result, "results")
assert.Equal(t, float64(3), result["total"])
})
t.Run("BulkDismissConflicts_InvalidRequestBody", func(t *testing.T) {
// Send invalid JSON
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json")))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
// TestConflictsBulkEscalate tests bulk escalate operations
// NOTE: This test is commented out because the /api/conflicts/bulk-escalate endpoint
// does not exist yet. It was planned in TEST_RELIABILITY_PLAN.md but never implemented.
// Uncomment and update when the endpoint is added.
/*
func TestConflictsBulkEscalate(t *testing.T) {
func TestBulkResolve_InvalidConflictID(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
t.Run("BulkEscalateConflicts_WithoutAuth", func(t *testing.T) {
req := map[string]interface{}{
"conflict_ids": []string{uuid.New().String()},
}
body, _ := json.Marshal(req)
req := handlers.BulkResolveRequest{
ConflictIDs: []string{"not-a-uuid"},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Run("BulkEscalateConflicts_EmptyConflictIDs", func(t *testing.T) {
req := map[string]interface{}{
"conflict_ids": []string{},
}
body, _ := json.Marshal(req)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
// Allow queue processor to process the item before querying for conflicts
time.Sleep(3 * time.Second)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
assert.Contains(t, result, "failed")
results := result["results"].([]interface{})
firstResult := results[0].(map[string]interface{})
assert.Equal(t, "error", firstResult["status"])
})
t.Run("BulkEscalateConflicts_MultipleConflicts", func(t *testing.T) {
conflictIDs := []string{
uuid.New().String(),
uuid.New().String(),
}
req := map[string]interface{}{
"conflict_ids": conflictIDs,
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result, "results")
assert.Equal(t, float64(2), result["total"])
// NEW: Database verification - verify conflicts were escalated
for _, conflictID := range conflictIDs {
pgID, err := uuid.Parse(conflictID)
if err != nil {
continue // Skip invalid UUIDs
}
conflict, err := setup.DB.GetSyncConflict(context.Background(), pgtype.UUID{Bytes: [16]byte(pgID), Valid: true})
if err == nil {
// If conflict exists, verify it was escalated
assert.Equal(t, "escalated", conflict.ResolutionStatus.String, "Conflict should be escalated")
}
}
})
assert.Equal(t, 1, result.Failed)
assert.Contains(t, result.Results[0].Error, "invalid conflict ID")
}
func TestBulkResolve_InvalidRequestBody(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestBulkResolve_RequiresAuth(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req := handlers.BulkResolveRequest{
ConflictIDs: []string{uuid.New().String()},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestBulkDismiss_RealConflicts(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
id1 := uuid.UUID(conflict1.ID.Bytes).String()
id2 := uuid.UUID(conflict2.ID.Bytes).String()
req := map[string]interface{}{
"conflict_ids": []string{id1, id2},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, float64(2), result["total"])
assert.Equal(t, float64(2), result["success"])
assert.Equal(t, float64(0), result["failed"])
results := result["results"].([]interface{})
require.Len(t, results, 2)
for _, r := range results {
entry := r.(map[string]interface{})
assert.Equal(t, "success", entry["status"])
}
}
func TestBulkDismiss_NotFoundConflict(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req := map[string]interface{}{
"conflict_ids": []string{uuid.New().String()},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, float64(1), result["total"])
assert.Equal(t, float64(0), result["success"])
assert.Equal(t, float64(1), result["failed"])
}
func TestBulkDismiss_InvalidConflictID(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req := map[string]interface{}{
"conflict_ids": []string{"invalid-uuid"},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, float64(1), result["total"])
assert.Equal(t, float64(1), result["failed"])
assert.Contains(t, result["results"].([]interface{})[0].(map[string]interface{})["error"], "invalid conflict ID")
}
func TestBulkDismiss_EmptyConflictIDs(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req := map[string]interface{}{
"conflict_ids": []string{},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestBulkDismiss_InvalidRequestBody(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json")))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestBulkDismiss_RequiresAuth(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req := map[string]interface{}{
"conflict_ids": []string{uuid.New().String()},
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestBulkResolve_MixedSuccessAndFailure(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
realID := uuid.UUID(conflict.ID.Bytes).String()
req := handlers.BulkResolveRequest{
ConflictIDs: []string{realID, uuid.New().String()},
Strategy: "most_recent",
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.BulkResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 2, result.Total)
assert.Equal(t, 1, result.Success)
assert.Equal(t, 1, result.Failed)
}
*/
+33 -13
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
@@ -40,7 +41,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
@@ -50,7 +53,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
sections, ok := response["sections"].([]interface{})
require.True(s.T(), ok, "sections should be an array")
require.Len(s.T(), sections, 4, "Should have 4 system collections")
require.Len(s.T(), sections, 5, "Should have 5 system collections")
// Verify response structure
sectionMap := make(map[string]map[string]interface{})
@@ -71,6 +74,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
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"]
@@ -87,7 +91,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
@@ -101,7 +107,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
@@ -112,7 +120,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_Unauthorized() {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
}
@@ -136,7 +146,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Success() {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
@@ -164,7 +176,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Unauthorized() {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
}
@@ -184,7 +198,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName(
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
}
@@ -201,7 +217,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(s.T(), err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
}
@@ -209,7 +227,7 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() {
token := s.setup.Token
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started"}
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started", "Continue Series"}
for _, collName := range validCollections {
s.T().Run(collName, func(t *testing.T) {
@@ -224,14 +242,16 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.NoError(s.T(), err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
require.NoError(s.T(), err)
assert.Contains(t, response, "message")
})
+45 -17
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -68,7 +69,9 @@ func TestUpdateUserMaxDevices(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected status code")
@@ -134,7 +137,9 @@ func TestUpdateUserMaxDevicesValidation(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected validation error")
})
@@ -166,7 +171,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -190,7 +197,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
@@ -221,7 +230,9 @@ func TestUpdateUserMaxDevicesNonExistentUser(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 500 or 404 depending on implementation
assert.True(t, resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusNotFound)
@@ -250,7 +261,9 @@ func TestUpdateUserMaxDevicesMissingUserID(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
@@ -271,12 +284,15 @@ func TestListUsersIncludesMaxDevices(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var users []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&users)
err = json.NewDecoder(resp.Body).Decode(&users)
require.NoError(t, err)
// Verify max_devices and device_count fields are present in response
if len(users) > 0 {
@@ -306,7 +322,7 @@ func createAdminUser(t *testing.T, ts *httptest.Server, token string) {
client := &http.Client{}
resp, _ := client.Do(req)
resp.Body.Close()
_ = resp.Body.Close()
}
// Helper function to create test user for max devices tests
@@ -327,7 +343,9 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Check if user creation succeeded or already exists (409 Conflict)
if resp.StatusCode == http.StatusConflict {
@@ -343,10 +361,13 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
loginResp, err := client.Do(loginReq)
require.NoError(t, err)
defer loginResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(loginResp.Body)
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
require.NoError(t, err)
// Extract user_id from JWT or response
// The access_token contains the user ID in the JWT claims
@@ -392,7 +413,8 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
}
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Check if user creation was successful
if result["user"] == nil {
@@ -422,10 +444,13 @@ func getAdminToken(t *testing.T, ts *httptest.Server, userID uuid.UUID) string {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Safe type assertion with check
if accessToken, ok := result["access_token"].(string); ok {
@@ -450,10 +475,13 @@ func loginTestUserByCredentials(t *testing.T, ts *httptest.Server, email, passwo
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// Safe type assertion with check
if accessToken, ok := result["access_token"].(string); ok {
+4 -2
View File
@@ -147,10 +147,12 @@ func TestUpdateDevice(t *testing.T) {
device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123")
// Update device
syncEnabled := false
syncFreq := int32(10)
updateRequest := handlers.DeviceUpdateRequest{
DeviceName: "Updated Device Name",
SyncEnabled: new(false),
SyncFrequencyMinutes: new(int32(10)),
SyncEnabled: &syncEnabled,
SyncFrequencyMinutes: &syncFreq,
}
updateBody, _ := json.Marshal(updateRequest)
+65 -27
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
@@ -19,7 +20,9 @@ func TestSavedFilters(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -30,12 +33,15 @@ func TestSavedFilters(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var filters []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&filters)
err = json.NewDecoder(resp.Body).Decode(&filters)
require.NoError(t, err)
assert.Equal(t, 0, len(filters))
})
@@ -56,12 +62,15 @@ func TestSavedFilters(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var filter map[string]interface{}
json.NewDecoder(resp.Body).Decode(&filter)
err = json.NewDecoder(resp.Body).Decode(&filter)
require.NoError(t, err)
assert.Equal(t, "My Sci-Fi Books", filter["name"])
assert.Equal(t, "media-items", filter["resource_type"])
assert.NotEmpty(t, filter["id"])
@@ -84,7 +93,7 @@ func TestSavedFilters(t *testing.T) {
resp1, err := client.Do(httpReq1)
require.NoError(t, err)
resp1.Body.Close()
_ = resp1.Body.Close()
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
@@ -96,7 +105,7 @@ func TestSavedFilters(t *testing.T) {
resp2, err := client.Do(httpReq2)
require.NoError(t, err)
resp2.Body.Close()
_ = resp2.Body.Close()
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
})
@@ -116,12 +125,15 @@ func TestSavedFilters(t *testing.T) {
createResp, err := client.Do(createHTTP)
require.NoError(t, err)
defer createResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(createResp.Body)
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
var createdFilter map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&createdFilter)
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
// Update filter
@@ -138,12 +150,15 @@ func TestSavedFilters(t *testing.T) {
updateResp, err := client.Do(updateHTTP)
require.NoError(t, err)
defer updateResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(updateResp.Body)
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
var updatedFilter map[string]interface{}
json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
require.NoError(t, err)
assert.Equal(t, "Updated Name", updatedFilter["name"])
})
@@ -162,10 +177,13 @@ func TestSavedFilters(t *testing.T) {
createResp, err := client.Do(createHTTP)
require.NoError(t, err)
defer createResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(createResp.Body)
var createdFilter map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&createdFilter)
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
// Delete filter
@@ -174,7 +192,7 @@ func TestSavedFilters(t *testing.T) {
deleteResp, err := client.Do(deleteHTTP)
require.NoError(t, err)
deleteResp.Body.Close()
_ = deleteResp.Body.Close()
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode)
})
@@ -198,10 +216,13 @@ func TestSavedFilters(t *testing.T) {
createResp, err := client.Do(createHTTP)
require.NoError(t, err)
defer createResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(createResp.Body)
var createdFilter map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&createdFilter)
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
// Admin user tries to delete regular user's filter
@@ -210,7 +231,7 @@ func TestSavedFilters(t *testing.T) {
deleteResp, err := client.Do(deleteHTTP)
require.NoError(t, err)
deleteResp.Body.Close()
_ = deleteResp.Body.Close()
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
})
@@ -230,12 +251,15 @@ func TestSavedFilters(t *testing.T) {
createResp, err := client.Do(createReq)
require.NoError(t, err)
defer createResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(createResp.Body)
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
var createdFilter map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&createdFilter)
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(string)
// Now retrieve the filter by ID
@@ -244,12 +268,15 @@ func TestSavedFilters(t *testing.T) {
getResp, err := client.Do(getReq)
require.NoError(t, err)
defer getResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(getResp.Body)
assert.Equal(t, http.StatusOK, getResp.StatusCode)
var retrievedFilter map[string]interface{}
json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
err = json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
require.NoError(t, err)
assert.Equal(t, "Test Filter", retrievedFilter["name"])
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
assert.Equal(t, filterID, retrievedFilter["id"])
@@ -260,7 +287,9 @@ func TestSavedFilters(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -271,7 +300,9 @@ func TestSavedFilters(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -283,7 +314,9 @@ func TestSavedFilters(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
@@ -301,16 +334,21 @@ func TestSavedFilters(t *testing.T) {
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
createResp, err := client.Do(createReq)
require.NoError(t, err)
defer createResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(createResp.Body)
var createdFilter map[string]interface{}
json.NewDecoder(createResp.Body).Decode(&createdFilter)
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
require.NoError(t, err)
filterID := createdFilter["id"].(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)
getResp, err := client.Do(httpReq)
require.NoError(t, err)
defer getResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(getResp.Body)
// Should return 404 (not 403 - hide existence)
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
})
+24 -11
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -31,10 +32,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
client := &http.Client{}
libResp, err := client.Do(libReq)
require.NoError(t, err)
defer libResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(libResp.Body)
require.Equal(t, http.StatusCreated, libResp.StatusCode)
var libResult map[string]interface{}
json.NewDecoder(libResp.Body).Decode(&libResult)
err = json.NewDecoder(libResp.Body).Decode(&libResult)
require.NoError(t, err)
libraryID := libResult["id"].(string)
// Add folder to library
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
@@ -47,7 +51,9 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
folderHTTPReq.Header.Set("Authorization", "Bearer "+token)
folderResp, err := client.Do(folderHTTPReq)
require.NoError(t, err)
defer folderResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(folderResp.Body)
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder should be added to library")
// Create 20 test files simultaneously
for i := 0; i < 20; i++ {
@@ -61,10 +67,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
scanHTTPReq.Header.Set("Authorization", "Bearer "+token)
scanResp, err := client.Do(scanHTTPReq)
require.NoError(t, err)
defer scanResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(scanResp.Body)
require.Equal(t, http.StatusAccepted, scanResp.StatusCode, "Scan should be accepted")
var scanResponse map[string]interface{}
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
require.NoError(t, err)
jobID, ok := scanResponse["job_id"].(string)
require.True(t, ok, "job_id should be string")
require.NotEmpty(t, jobID, "job_id should not be empty")
@@ -81,16 +90,17 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
require.NoError(t, err)
if statusResp.StatusCode == http.StatusNotFound {
statusResp.Body.Close()
_ = statusResp.Body.Close()
break // Job completed
}
var status map[string]interface{}
json.NewDecoder(statusResp.Body).Decode(&status)
statusResp.Body.Close()
err = json.NewDecoder(statusResp.Body).Decode(&status)
require.NoError(t, err)
_ = statusResp.Body.Close()
if status["status"] == "completed" || status["status"] == "failed" {
statusResp.Body.Close()
_ = statusResp.Body.Close()
break
}
}
@@ -99,9 +109,12 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+token)
itemsResp, err := client.Do(req)
require.NoError(t, err)
defer itemsResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(itemsResp.Body)
var itemsResult map[string]interface{}
json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
err = json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
require.NoError(t, err)
items, ok := itemsResult["data"].([]interface{})
if !ok || items == nil {
items = []interface{}{} // Handle nil or wrong type
+26 -9
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
@@ -33,7 +34,9 @@ func TestJobsHandler_CreateJob(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusAccepted, resp.StatusCode)
@@ -65,7 +68,9 @@ func TestJobsHandler_CreateJob_InvalidType(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
@@ -88,7 +93,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
client := &http.Client{}
createResp, err := client.Do(createReq)
require.NoError(t, err)
defer createResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(createResp.Body)
var createResponse map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
@@ -103,7 +110,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
getResp, err := client.Do(getReq)
require.NoError(t, err)
defer getResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(getResp.Body)
require.Equal(t, http.StatusOK, getResp.StatusCode)
@@ -128,7 +137,9 @@ func TestJobsHandler_GetJobStatus_NotFound(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
}
@@ -151,7 +162,9 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
client := &http.Client{}
createResp, err := client.Do(createReq)
require.NoError(t, err)
defer createResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(createResp.Body)
var createResponse map[string]interface{}
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
@@ -172,7 +185,7 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
var statusResponse map[string]interface{}
err = json.NewDecoder(getResp.Body).Decode(&statusResponse)
getResp.Body.Close()
_ = getResp.Body.Close()
require.NoError(t, err)
if statusResponse["status"] != nil {
@@ -202,7 +215,9 @@ func TestJobsHandler_CreateJob_Unauthorized(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
@@ -218,7 +233,9 @@ func TestJobsHandler_GetJobStatus_Unauthorized(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
+22 -8
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
@@ -37,7 +38,9 @@ func TestKoboInitialization(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
@@ -65,7 +68,9 @@ func TestKoboLibrarySync(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
@@ -129,11 +134,14 @@ func TestKoboMarkupSync(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "Status")
})
}
@@ -182,11 +190,14 @@ func TestKoboBookmarkSync(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "Status")
})
}
@@ -224,11 +235,14 @@ func TestKoboAnalyticsGettests(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "Status")
})
}
+2 -2
View File
@@ -182,13 +182,13 @@ func TestLibraryTypesResponse(t *testing.T) {
"id": "test-id-2",
"name": "comics",
"description": "Comic book archives and image formats",
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
},
{
"id": "test-id-3",
"name": "manga",
"description": "Manga files including archives and image folders",
"allowed_extensions": []string{".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"},
"allowed_extensions": []string{".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
},
}
@@ -556,7 +556,7 @@ func TestLibraryTypes(t *testing.T) {
"id": uuid.New().String(),
"name": "comics",
"description": "Comic book archives and image formats",
"allowed_extensions": []string{".cbz", ".cbr", ".pdf"},
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
},
}
w.WriteHeader(http.StatusOK)
+49 -18
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
@@ -31,7 +32,9 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
@@ -58,7 +61,9 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -76,12 +81,15 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
@@ -106,12 +114,15 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Equal(t, 3.0, result["total"])
@@ -130,7 +141,9 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -154,7 +167,9 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
@@ -179,7 +194,9 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -204,12 +221,15 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
@@ -246,12 +266,15 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Equal(t, 2.0, result["total"])
@@ -304,12 +327,15 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
assert.Contains(t, result, "total")
@@ -352,12 +378,15 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Contains(t, result, "results")
})
@@ -371,7 +400,9 @@ func TestMediaBulkOperations(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
+58 -23
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -31,12 +32,15 @@ func createTestLibrary(t *testing.T, ts *httptest.Server, token, name string) st
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
return result["id"].(string)
}
@@ -164,7 +168,9 @@ func TestMediaItemISBNNormalization(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Check if this is an invalid ISBN case that should return 422
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
@@ -174,7 +180,8 @@ func TestMediaItemISBNNormalization(t *testing.T) {
}
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// For valid ISBN responses, verify normalization worked correctly
if resp.StatusCode == http.StatusCreated {
@@ -209,7 +216,9 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
})
@@ -232,10 +241,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "9780306406157", response["isbn"])
@@ -259,10 +271,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "9780596009652", response["isbn"])
@@ -296,7 +311,7 @@ func TestMediaItemsPagination(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
resp.Body.Close()
_ = resp.Body.Close()
}
// Small delay to allow database to commit before pagination queries
@@ -309,12 +324,15 @@ func TestMediaItemsPagination(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
data := response["data"].([]interface{})
// Should get 2 items
@@ -328,12 +346,15 @@ func TestMediaItemsPagination(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
data := response["data"].([]interface{})
// Should get 2 items starting from offset 2
@@ -347,7 +368,9 @@ func TestMediaItemsPagination(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 400 Bad Request or handle it gracefully
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
@@ -360,7 +383,9 @@ func TestMediaItemsPagination(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 400 Bad Request or handle it gracefully
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
@@ -373,7 +398,9 @@ func TestMediaItemsPagination(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should be capped at maximum or return error
// The application uses maxPaginationLimit = 1000
@@ -404,7 +431,9 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should fail - library_id is required
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
@@ -431,12 +460,15 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// VerifyISBN was normalized
assert.Equal(t, "9780306406157", response["isbn"])
@@ -472,8 +504,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
require.NoError(t, err)
var createResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&createResponse)
resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&createResponse)
require.NoError(t, err)
_ = resp.Body.Close()
mediaItemID := createResponse["id"].(string)
@@ -491,7 +524,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
})
+69 -23
View File
@@ -26,7 +26,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// OPDS endpoints require device authentication via devices.auth_token
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
@@ -37,7 +39,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 400 for invalid UUID
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
@@ -53,7 +57,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 200 with catalog (even if empty)
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -69,7 +75,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 200 with catalog (even if empty)
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -80,7 +88,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -95,7 +105,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 200 (even if empty results)
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -106,7 +118,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -120,7 +134,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return navigation or 404
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
@@ -131,7 +147,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -142,7 +160,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -156,7 +176,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// May return 404 if device/book not linked, or 500 for file not found
// Should not return 400 (invalid IDs)
@@ -168,7 +190,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -178,7 +202,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -192,7 +218,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// May return 404 if no cover, but not 400
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
@@ -204,7 +232,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -218,7 +248,9 @@ func TestOPDSEndpoints(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return formats list or 404
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
@@ -245,7 +277,9 @@ func TestOPDSConversion(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should attempt conversion (may fail if file doesn't exist)
// Important: Should not return 400 for invalid IDs
@@ -262,7 +296,9 @@ func TestOPDSConversion(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should attempt to download original format
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
@@ -278,7 +314,9 @@ func TestOPDSConversion(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle gracefully (either 400 for unsupported format or 404/500)
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
@@ -300,7 +338,9 @@ func TestOPDSEdgeCases(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return empty catalog, not error
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -316,7 +356,9 @@ func TestOPDSEdgeCases(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle special characters
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -331,7 +373,9 @@ func TestOPDSEdgeCases(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle empty query
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
@@ -371,7 +415,9 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
t.Logf("OPDS Search Status: %d", resp.StatusCode)
+11 -9
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
@@ -108,8 +109,8 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
{
name: "Empty UUID",
libraryID: "",
expectedStatus: http.StatusNotFound,
description: "Should return 404 for empty ID",
expectedStatus: http.StatusBadRequest,
description: "Should return 400 for empty ID",
},
{
name: "UUID with extra path traversal",
@@ -139,7 +140,7 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/list", nil)
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
@@ -284,8 +285,8 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
{
name: "Empty UUID",
libraryID: "",
expectedStatus: http.StatusNotFound,
description: "Should return 404 for empty ID",
expectedStatus: http.StatusBadRequest,
description: "Should return 400 for empty ID",
},
{
name: "UUID with extra path traversal",
@@ -315,7 +316,7 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/stats", nil)
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
@@ -388,7 +389,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
var stats1 map[string]interface{}
json.NewDecoder(rec1.Body).Decode(&stats1)
err := json.NewDecoder(rec1.Body).Decode(&stats1)
require.NoError(t, err)
// Get stats for library 2
req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil)
@@ -398,7 +400,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
setup.Server.Config.Handler.ServeHTTP(rec2, req2)
var stats2 map[string]interface{}
json.NewDecoder(rec2.Body).Decode(&stats2)
err = json.NewDecoder(rec2.Body).Decode(&stats2)
require.NoError(t, err)
// Both should have zero counts
assert.Equal(t, float64(0), stats1["error_count"])
@@ -424,7 +427,6 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
{"Ebooks library", "ebooks"},
{"Comics library", "comics"},
{"Manga library", "manga"},
{"Audiobooks library", "audiobooks"},
}
for _, lt := range libraryTypes {
+658
View File
@@ -0,0 +1,658 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func pFloat64(v float64) *float64 { return &v }
func pStr(v string) *string { return &v }
func pInt(v int) *int { return &v }
func pInt64(v int64) *int64 { return &v }
func doReq(t *testing.T, method, url string, body interface{}, token string) *http.Response {
t.Helper()
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
require.NoError(t, err)
bodyReader = bytes.NewBuffer(b)
}
req, err := http.NewRequest(method, url, bodyReader)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
return resp
}
func decodeJSON(t *testing.T, resp *http.Response) map[string]interface{} {
t.Helper()
var result map[string]interface{}
err := json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
return result
}
func progressURL(serverURL, mediaItemID string) string {
return serverURL + "/api/media-items/" + mediaItemID + "/progress"
}
func getFloatField(t *testing.T, data map[string]interface{}, field string) float64 {
t.Helper()
val, ok := data[field]
require.True(t, ok, "%s should be present in response", field)
require.NotNil(t, val, "%s should not be null", field)
f, ok := val.(float64)
require.True(t, ok, "%s should be a number, got %T: %v", field, val, val)
return f
}
func getStringField(t *testing.T, data map[string]interface{}, field string) string {
t.Helper()
val, ok := data[field]
require.True(t, ok, "%s should be present in response", field)
require.NotNil(t, val, "%s should not be null", field)
s, ok := val.(string)
require.True(t, ok, "%s should be a string, got %T: %v", field, val, val)
return s
}
func TestProgressWeb_AuthContexts(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("unauthenticated PUT returns 401", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.5,
}, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("unauthenticated GET returns 401", func(t *testing.T) {
resp := doReq(t, "GET", url, nil, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("regular user PUT succeeds", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.3,
}, setup.RegularToken)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("regular user GET succeeds", func(t *testing.T) {
resp := doReq(t, "GET", url, nil, setup.RegularToken)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("admin PUT succeeds", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.7,
}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("admin GET succeeds", func(t *testing.T) {
resp := doReq(t, "GET", url, nil, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("invalid media item ID returns 400", func(t *testing.T) {
badURL := setup.Server.URL + "/api/media-items/not-a-uuid/progress"
resp := doReq(t, "GET", badURL, nil, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("nonexistent media item GET returns 200 with empty", func(t *testing.T) {
fakeID := uuid.New().String()
fakeURL := progressURL(setup.Server.URL, fakeID)
resp := doReq(t, "GET", fakeURL, nil, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestProgressWeb_MergePreservesFields(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("second PUT with only percentage preserves epubcfi and chapter from first", func(t *testing.T) {
resp1 := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.3,
"epubcfi": "epubcfi(/6/4/2:first)",
"chapter": 2,
}, setup.Token)
defer resp1.Body.Close()
require.Equal(t, http.StatusOK, resp1.StatusCode)
resp2 := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.5,
}, setup.Token)
defer resp2.Body.Close()
require.Equal(t, http.StatusOK, resp2.StatusCode)
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode)
result := decodeJSON(t, getResp)
pct := getFloatField(t, result, "percentage")
assert.InDelta(t, 0.5, pct, 0.01)
epubcfi := getStringField(t, result, "epubcfi")
assert.Equal(t, "epubcfi(/6/4/2:first)", epubcfi, "epubcfi should be preserved from first save")
chapter := getFloatField(t, result, "chapter")
assert.Equal(t, float64(2), chapter, "chapter should be preserved from first save")
})
t.Run("web save preserves koreader character_offset", func(t *testing.T) {
mediaItemID2 := createTestMediaItemID(t, setup)
url2 := progressURL(setup.Server.URL, mediaItemID2)
mediaUUID, _ := uuid.Parse(mediaItemID2)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
charOffset := int64(15000)
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "koreader",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: pFloat64(0.45),
CharacterOffset: &charOffset,
Chapter: pInt(5),
DeviceType: "koreader",
DeviceName: "KOReader Test",
Broadcast: false,
})
require.NoError(t, err)
resp := doReq(t, "PUT", url2, map[string]interface{}{
"percentage": 0.5,
"epubcfi": "epubcfi(/6/4/2:10)",
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be preserved")
assert.Equal(t, int64(15000), progress.CharacterOffset.Int64)
assert.True(t, progress.Chapter.Valid, "chapter should be preserved")
assert.Equal(t, int32(5), progress.Chapter.Int32)
assert.InDelta(t, 0.5, progress.Percentage.Float64, 0.001)
assert.Equal(t, "web", progress.LastSyncSource.String)
})
}
func TestProgressWeb_EnrichmentComputesFields(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
mediaUUID, _ := uuid.Parse(mediaItemID)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
t.Run("character_offset computed from percentage when total_characters set", func(t *testing.T) {
_, err := setup.DBPool.Exec(ctx, "UPDATE media_items SET total_characters = $1 WHERE id = $2", int64(200000), mediaUUID)
require.NoError(t, err)
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.5,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be computed from percentage")
assert.Equal(t, int64(100000), progress.CharacterOffset.Int64)
})
t.Run("GET returns enriched format_group and total_characters", func(t *testing.T) {
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode)
result := decodeJSON(t, getResp)
_, hasFormatGroup := result["format_group"]
assert.True(t, hasFormatGroup, "format_group should be present in GET response")
_, hasTotalChars := result["total_characters"]
assert.True(t, hasTotalChars, "total_characters should be present in GET response")
})
}
func TestProgressWeb_ConflictDetection(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
mediaUUID, _ := uuid.Parse(mediaItemID)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
t.Run("different sources with >1% diff within 5min creates conflict record", func(t *testing.T) {
pct1 := 0.3
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "koreader",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct1,
DeviceType: "koreader",
DeviceName: "KOReader",
Broadcast: false,
})
require.NoError(t, err)
pct2 := 0.6
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct2,
DeviceType: "kobo",
DeviceName: "Kobo",
Broadcast: false,
})
require.NoError(t, err)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001)
assert.Equal(t, "kobo", progress.LastSyncSource.String)
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.NotEmpty(t, conflicts, "conflict should be recorded in sync_conflicts table")
assert.Equal(t, "progress", conflicts[0].ConflictType)
assert.True(t, conflicts[0].ResolutionStatus.Valid)
assert.Equal(t, "unresolved", conflicts[0].ResolutionStatus.String)
})
mediaItemID2 := createTestMediaItemID(t, setup)
mediaUUID2, _ := uuid.Parse(mediaItemID2)
t.Run("same source rapid saves create no conflict", func(t *testing.T) {
for _, pct := range []float64{0.1, 0.3, 0.5, 0.7} {
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "web",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct,
DeviceType: "web",
DeviceName: "Web",
Broadcast: false,
})
require.NoError(t, err)
}
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.Empty(t, conflicts, "same-source saves should not create conflicts")
})
mediaItemID3 := createTestMediaItemID(t, setup)
mediaUUID3, _ := uuid.Parse(mediaItemID3)
t.Run("different sources with <1% diff creates no conflict", func(t *testing.T) {
pct1 := 0.5
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "koreader",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct1,
DeviceType: "koreader",
DeviceName: "KOReader",
Broadcast: false,
})
require.NoError(t, err)
pct2 := 0.505
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
Percentage: &pct2,
DeviceType: "kobo",
DeviceName: "Kobo",
Broadcast: false,
})
require.NoError(t, err)
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.Empty(t, conflicts, "small percentage diff should not create conflict")
})
}
func TestProgressWeb_KoboIntegration(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
userID := getTestUserID(t, setup.DB)
koboDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: "Test Kobo Progress",
DeviceType: "kobo",
DeviceIdentifier: "kobo-progress-test",
AuthToken: koboDeviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create kobo device")
_ = device
t.Run("unauthenticated Kobo markup returns 401", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/kobo/invalid-token/markup", map[string]interface{}{
"ReadingSync": []map[string]interface{}{
{"ContentId": uuid.New().String(), "PercentRead": 50.0},
},
}, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
mediaItemID := createTestMediaItemID(t, setup)
t.Run("ReadingSync then last-read-place preserves percentage", func(t *testing.T) {
readingSyncBody := map[string]interface{}{
"ReadingSync": []map[string]interface{}{
{
"ContentId": mediaItemID,
"PercentRead": 55.0,
},
},
}
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), readingSyncBody, "")
defer resp.Body.Close()
io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
time.Sleep(100 * time.Millisecond)
bookmarkBody := map[string]interface{}{
"BookmarkSync": []map[string]interface{}{
{
"ContentId": mediaItemID,
"BookmarkId": "epubcfi(/6/4!/4/2/1:0)",
"BookmarkType": "last-read-place",
"Chapter": 5,
},
},
}
resp2 := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
defer resp2.Body.Close()
io.ReadAll(resp2.Body)
assert.Equal(t, http.StatusOK, resp2.StatusCode)
mediaUUID, _ := uuid.Parse(mediaItemID)
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.Percentage.Valid)
assert.InDelta(t, 0.55, progress.Percentage.Float64, 0.01, "percentage should still be 55% from ReadingSync")
})
t.Run("Kobo last-read-place without prior ReadingSync sets epubcfi and chapter", func(t *testing.T) {
newMediaID := createTestMediaItemID(t, setup)
newMediaUUID, _ := uuid.Parse(newMediaID)
bookmarkBody := map[string]interface{}{
"BookmarkSync": []map[string]interface{}{
{
"ContentId": newMediaID,
"BookmarkId": "epubcfi(/6/14!/4/2/1:0)",
"BookmarkType": "last-read-place",
"Chapter": 3,
},
},
}
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
defer resp.Body.Close()
io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: newMediaUUID, Valid: true},
UserID: pgUserID,
})
require.NoError(t, err)
assert.True(t, progress.Epubcfi.Valid, "epubcfi should be set from last-read-place")
assert.True(t, progress.Chapter.Valid, "chapter should be set from last-read-place")
assert.Equal(t, int32(3), progress.Chapter.Int32)
})
}
func TestProgressWeb_KOReaderIntegration(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
koreaderDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
_, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: "Test KOReader Progress",
DeviceType: "koreader",
DeviceIdentifier: "koreader-progress-test",
AuthToken: koreaderDeviceToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create koreader device")
t.Run("KOReader progress sync via HTTP", func(t *testing.T) {
progressBody := map[string]interface{}{
"books": []map[string]interface{}{
{
"file_path": "/tmp/test.epub",
"percentage": 0.42,
"chapter": 3,
"device_info": map[string]interface{}{
"device_model": "Test Device",
"koreader_version": "1.0",
},
},
},
}
bodyBytes, _ := json.Marshal(progressBody)
req, err := http.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(bodyBytes))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+koreaderDeviceToken)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
io.ReadAll(resp.Body)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
mediaUUID, _ := uuid.Parse(mediaItemID)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgUserID,
})
if err == nil {
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
}
})
t.Run("unauthenticated KOReader sync returns 401", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", map[string]interface{}{
"books": []map[string]interface{}{
{"file_path": "/tmp/test.epub", "percentage": 0.5},
},
}, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
func TestProgressWeb_DeleteProgress(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("DELETE removes progress", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.75,
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 7,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
delResp := doReq(t, "DELETE", url, nil, setup.Token)
defer delResp.Body.Close()
assert.Equal(t, http.StatusOK, delResp.StatusCode)
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
result := decodeJSON(t, getResp)
assert.Equal(t, float64(0), result["current_page"], "progress should be cleared after delete")
})
t.Run("DELETE without auth returns 401", func(t *testing.T) {
resp := doReq(t, "DELETE", url, nil, "")
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
func TestProgressWeb_EdgeCases(t *testing.T) {
setup := setupTestServer(t)
mediaItemID := createTestMediaItemID(t, setup)
url := progressURL(setup.Server.URL, mediaItemID)
t.Run("PUT with empty body still succeeds", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("PUT percentage 0.0", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 0.0,
}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
getResp := doReq(t, "GET", url, nil, setup.Token)
defer getResp.Body.Close()
require.Equal(t, http.StatusOK, getResp.StatusCode)
})
t.Run("PUT percentage 1.0", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{
"percentage": 1.0,
}, setup.Token)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("PUT with all fields then GET verifies each", func(t *testing.T) {
mediaItemID2 := createTestMediaItemID(t, setup)
url2 := progressURL(setup.Server.URL, mediaItemID2)
mediaUUID2, _ := uuid.Parse(mediaItemID2)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
resp := doReq(t, "PUT", url2, map[string]interface{}{
"percentage": 0.42,
"current_page": 84,
"total_pages": 200,
"epubcfi": "epubcfi(/6/4!/4/2/1:0)",
"chapter": 3,
"chapter_progress": 0.5,
"character_offset": 15000,
"reading_mode": "page",
"zoom_level": 1.5,
"scroll_position_x": 0.0,
"scroll_position_y": 100.0,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
})
require.NoError(t, err)
assert.True(t, progress.Percentage.Valid)
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
assert.True(t, progress.Chapter.Valid)
assert.Equal(t, int32(3), progress.Chapter.Int32)
assert.True(t, progress.Epubcfi.Valid)
assert.Equal(t, "epubcfi(/6/4!/4/2/1:0)", progress.Epubcfi.String)
assert.True(t, progress.CurrentPage.Valid)
assert.Equal(t, int32(84), progress.CurrentPage.Int32)
assert.True(t, progress.TotalPages.Valid)
assert.Equal(t, int32(200), progress.TotalPages.Int32)
assert.True(t, progress.ReadingMode.Valid)
assert.Equal(t, "page", progress.ReadingMode.String)
assert.True(t, progress.ZoomLevel.Valid)
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
})
}
+60 -22
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
@@ -24,7 +25,9 @@ func TestRefreshTokenFlow(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -40,7 +43,9 @@ func TestRefreshTokenFlow(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -58,12 +63,15 @@ func TestRefreshTokenFlow(t *testing.T) {
loginResp, err := client.Do(loginHTTP)
require.NoError(t, err)
defer loginResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(loginResp.Body)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
require.NoError(t, err)
refreshToken, ok := loginResult["refresh_token"].(string)
require.True(t, ok, "Should have refresh_token")
@@ -79,12 +87,15 @@ func TestRefreshTokenFlow(t *testing.T) {
refreshResp, err := client.Do(refreshHTTP)
require.NoError(t, err)
defer refreshResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(refreshResp.Body)
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
var refreshResult map[string]interface{}
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
require.NoError(t, err)
assert.Contains(t, refreshResult, "access_token")
assert.NotEmpty(t, refreshResult["access_token"], "New access token should not be empty")
@@ -102,7 +113,9 @@ func TestRefreshTokenFlow(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -118,7 +131,9 @@ func TestRefreshTokenFlow(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should still work or return appropriate error
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnsupportedMediaType)
@@ -143,12 +158,15 @@ func TestRefreshTokenSecurity(t *testing.T) {
loginResp, err := client.Do(loginHTTP)
require.NoError(t, err)
defer loginResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(loginResp.Body)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
require.NoError(t, err)
refreshToken := loginResult["refresh_token"].(string)
@@ -163,7 +181,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
refreshResp1, err := client.Do(refreshHTTP1)
require.NoError(t, err)
defer refreshResp1.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(refreshResp1.Body)
assert.Equal(t, http.StatusOK, refreshResp1.StatusCode)
@@ -173,7 +193,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
refreshResp2, err := client.Do(refreshHTTP2)
require.NoError(t, err)
defer refreshResp2.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(refreshResp2.Body)
// May return 401 if token reuse is detected, or 200 if not implemented
// Either is acceptable depending on security requirements
@@ -197,7 +219,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -213,7 +237,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
@@ -231,12 +257,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
loginResp, err := client.Do(loginHTTP)
require.NoError(t, err)
defer loginResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(loginResp.Body)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
require.NoError(t, err)
refreshToken := loginResult["refresh_token"].(string)
@@ -251,12 +280,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
refreshResp, err := client.Do(refreshHTTP)
require.NoError(t, err)
defer refreshResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(refreshResp.Body)
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
var refreshResult map[string]interface{}
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
require.NoError(t, err)
// Verify response structure
assert.Contains(t, refreshResult, "access_token")
@@ -279,12 +311,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
loginResp, err := client.Do(loginHTTP)
require.NoError(t, err)
defer loginResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(loginResp.Body)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
require.NoError(t, err)
refreshToken := loginResult["refresh_token"].(string)
@@ -299,12 +334,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
refreshResp, err := client.Do(refreshHTTP)
require.NoError(t, err)
defer refreshResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(refreshResp.Body)
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
var refreshResult map[string]interface{}
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
require.NoError(t, err)
// Verify access token is a string
accessToken, ok := refreshResult["access_token"].(string)
@@ -25,8 +25,9 @@ func TestScanSettings_GetSettings(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Contains(t, response, "scan_poll_interval_seconds")
assert.Contains(t, response, "auto_scan_enabled")
@@ -63,8 +64,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, float64(45), response["scan_poll_interval_seconds"])
assert.Equal(t, true, response["auto_scan_enabled"])
@@ -74,8 +76,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
getResp, _ := client.Do(getReq)
var getResponse map[string]interface{}
json.NewDecoder(getResp.Body).Decode(&getResponse)
getResp.Body.Close()
err = json.NewDecoder(getResp.Body).Decode(&getResponse)
require.NoError(t, err)
_ = getResp.Body.Close()
assert.Equal(t, float64(45), getResponse["scan_poll_interval_seconds"])
})
@@ -99,8 +102,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, false, response["auto_scan_enabled"])
})
+16 -13
View File
@@ -46,8 +46,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
require.Equal(s.T(), http.StatusCreated, createLibResp.StatusCode)
var createLibResponse map[string]interface{}
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
createLibResp.Body.Close()
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
require.NoError(s.T(), err)
_ = createLibResp.Body.Close()
libraryID, ok := createLibResponse["id"].(string)
require.True(s.T(), ok, "library_id should be string")
@@ -64,7 +65,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
resp, err := client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
_ = resp.Body.Close()
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Folder creation should succeed")
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
@@ -78,7 +79,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
var scanResponse map[string]interface{}
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
require.NoError(s.T(), err)
scanResp.Body.Close()
_ = scanResp.Body.Close()
jobID, ok := scanResponse["job_id"].(string)
require.True(s.T(), ok, "job_id should be string")
@@ -104,7 +105,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
require.NoError(s.T(), err)
if statusResp.StatusCode == http.StatusNotFound {
statusResp.Body.Close()
_ = statusResp.Body.Close()
if gotProgressUpdate {
break
}
@@ -113,7 +114,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
var status map[string]interface{}
err = json.NewDecoder(statusResp.Body).Decode(&status)
statusResp.Body.Close()
_ = statusResp.Body.Close()
require.NoError(s.T(), err)
if _, hasError := status["error"]; hasError {
@@ -180,8 +181,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
require.NoError(s.T(), err)
var createLibResponse map[string]interface{}
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
createLibResp.Body.Close()
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
require.NoError(s.T(), err)
_ = createLibResp.Body.Close()
libraryID, ok := createLibResponse["id"].(string)
require.True(s.T(), ok, "library_id should be string")
@@ -198,7 +200,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
resp, err := client.Do(req)
require.NoError(s.T(), err)
resp.Body.Close()
_ = resp.Body.Close()
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
scanReq, _ := http.NewRequest("POST", scanURL, nil)
@@ -208,8 +210,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
require.NoError(s.T(), err)
var scanResponse map[string]interface{}
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
scanResp.Body.Close()
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
require.NoError(s.T(), err)
_ = scanResp.Body.Close()
jobID, ok := scanResponse["job_id"].(string)
require.True(s.T(), ok, "job_id should be string")
@@ -231,14 +234,14 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
require.NoError(s.T(), err)
if statusResp.StatusCode == http.StatusNotFound {
statusResp.Body.Close()
_ = statusResp.Body.Close()
break
}
var status map[string]interface{}
err = json.NewDecoder(statusResp.Body).Decode(&status)
require.NoError(s.T(), err)
statusResp.Body.Close()
_ = statusResp.Body.Close()
if _, hasError := status["error"]; hasError {
continue
+23 -14
View File
@@ -388,6 +388,7 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
query string
libraryID string
expectedCount int
expectedStatus int
shouldContain string // Comma-separated list of book IDs to check
}{
{
@@ -412,10 +413,10 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
shouldContain: book2ID,
},
{
name: "invalid library_id",
query: "Harry",
libraryID: "00000000-0000-0000-0000-000000000000",
expectedCount: 0,
name: "invalid library_id",
query: "Harry",
libraryID: "00000000-0000-0000-0000-000000000000",
expectedStatus: http.StatusNotFound,
},
}
@@ -431,16 +432,18 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Log response for debugging
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Logf("ERROR %d: %s", resp.StatusCode, string(bodyBytes))
if tt.expectedStatus != 0 {
require.Equal(t, tt.expectedStatus, resp.StatusCode)
return
}
var result []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
if tt.expectedCount > 0 {
require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -483,11 +486,14 @@ func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, na
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
return result
}
@@ -509,10 +515,13 @@ func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *Te
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
return result["id"].(string)
}
+10 -7
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -30,7 +31,7 @@ func TestUnifiedSearch(t *testing.T) {
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
folderResp, err := client.Do(folderHTTP)
require.NoError(t, err)
folderResp.Body.Close()
_ = folderResp.Body.Close()
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
// Helper to create book with fields
@@ -54,7 +55,9 @@ func TestUnifiedSearch(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
@@ -90,14 +93,14 @@ func TestUnifiedSearch(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
})
t.Run("Missing library_id", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil)
t.Run("Missing library_id searches all libraries", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/media-items/search?q=zzzznonexistent", nil)
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
// library_id is now optional - searches all libraries when omitted
// Returns 404 when no results match the search query
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results found")
// library_id is optional - searches all libraries when omitted
// Returns 404 when no results match
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results match")
})
}
+361
View File
@@ -0,0 +1,361 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type SeriesIntegrationTestSuite struct {
suite.Suite
setup *TestServerSetup
token string
libraryID string
}
func (s *SeriesIntegrationTestSuite) SetupSuite() {
s.setup = setupTestServer(s.T())
s.token = s.setup.Token
s.libraryID = createTestLibraryWithFolder(s.T(), s.setup.Server, s.token, "Test Series Library", false)
}
func (s *SeriesIntegrationTestSuite) TearDownSuite() {
s.setup.Close()
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "library_id required", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id=not-a-uuid", nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "invalid library_id", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_EmptyLibrary() {
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
series, ok := body["series"].([]interface{})
require.True(s.T(), ok, "series should be an array")
assert.Empty(s.T(), series, "empty library should have no series")
total, ok := body["total"].(float64)
require.True(s.T(), ok, "total should be a number")
assert.Equal(s.T(), float64(0), total)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_Unauthorized() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
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.StatusUnauthorized, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_PaginationParams() {
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=5&offset=0", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
limit, ok := body["limit"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(5), limit)
offset, ok := body["offset"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(0), offset)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=999", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
limit, ok := body["limit"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "library_id required", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
url := fmt.Sprintf("%s/api/series/books?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]string
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Equal(s.T(), "name required", body["error"])
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
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)
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 *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=Nonexistent+Series", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
books, ok := body["books"].([]interface{})
require.True(s.T(), ok, "books should be an array")
assert.Empty(s.T(), books, "nonexistent series should return empty books array")
assert.Equal(s.T(), "Nonexistent Series", body["name"])
total, ok := body["total"].(float64)
require.True(s.T(), ok)
assert.Equal(s.T(), float64(0), total)
}
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_Unauthorized() {
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id="+s.libraryID+"&name=Test", nil)
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.StatusUnauthorized, resp.StatusCode)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_SpecialCharactersInName() {
seriesName := "Series: Book & Other (Vol. 1)"
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=%s", s.setup.Server.URL, s.libraryID, url.QueryEscape(seriesName))
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
}
func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
var body map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&body)
require.NoError(s.T(), err)
assert.Contains(s.T(), body, "series", "response should contain 'series' key")
assert.Contains(s.T(), body, "total", "response should contain 'total' key")
assert.Contains(s.T(), body, "limit", "response should contain 'limit' key")
assert.Contains(s.T(), body, "offset", "response should contain 'offset' key")
_, ok := body["series"].([]interface{})
assert.True(s.T(), ok, "'series' should be an array")
}
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
reqBody := map[string]interface{}{
"collection_name": "Continue Series",
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.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)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(s.T(), err)
assert.Contains(s.T(), response, "message")
}
func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, s.libraryID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+s.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)
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")
require.Len(s.T(), sections, 5, "Should have 5 system collections")
sectionIDs := make(map[string]bool)
for _, sec := range sections {
section := sec.(map[string]interface{})
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")
}
func TestSeriesIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(SeriesIntegrationTestSuite))
}
+48 -19
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const baseTestURL = "http://localhost:8765/api"
@@ -36,12 +37,15 @@ func TestFullApplicationSetup(t *testing.T) {
t.Logf("Cleanup: No existing test user to delete (server not available)")
return
}
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// If login succeeds, try to delete the user
if resp.StatusCode == http.StatusOK {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
if token, ok := result["access_token"].(string); ok && token != "" {
// Delete the user using the token
@@ -52,7 +56,9 @@ func TestFullApplicationSetup(t *testing.T) {
client := &http.Client{}
delResp, err := client.Do(req)
if err == nil {
defer delResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(delResp.Body)
if delResp.StatusCode == http.StatusNoContent {
t.Logf("Cleanup: Deleted existing test user")
} else {
@@ -66,10 +72,13 @@ func TestFullApplicationSetup(t *testing.T) {
listResp, err := client.Do(req)
if err == nil {
defer listResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(listResp.Body)
if listResp.StatusCode == http.StatusOK {
var libsResult map[string]interface{}
json.NewDecoder(listResp.Body).Decode(&libsResult)
err = json.NewDecoder(listResp.Body).Decode(&libsResult)
require.NoError(t, err)
if data, ok := libsResult["data"].([]interface{}); ok {
for _, lib := range data {
@@ -80,7 +89,7 @@ func TestFullApplicationSetup(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+token)
delLibResp, _ := client.Do(req)
if delLibResp != nil {
delLibResp.Body.Close()
_ = delLibResp.Body.Close()
}
}
}
@@ -108,7 +117,9 @@ func TestFullApplicationSetup(t *testing.T) {
body, _ := json.Marshal(userReq)
resp, err := http.Post(baseTestURL+"/auth/register", "application/json", bytes.NewBuffer(body))
assert.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Accept 201 (Created) or 409 (Conflict if already exists from previous incomplete test run)
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
@@ -116,7 +127,8 @@ func TestFullApplicationSetup(t *testing.T) {
}
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// If we got 409, the user already exists, so we need to login to get the token
if resp.StatusCode == http.StatusConflict {
@@ -128,10 +140,13 @@ func TestFullApplicationSetup(t *testing.T) {
body, _ := json.Marshal(loginReq)
resp2, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
assert.NoError(t, err)
defer resp2.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
assert.Equal(t, http.StatusOK, resp2.StatusCode)
json.NewDecoder(resp2.Body).Decode(&result)
err = json.NewDecoder(resp2.Body).Decode(&result)
require.NoError(t, err)
}
if result["user"] != nil {
@@ -155,12 +170,15 @@ func TestFullApplicationSetup(t *testing.T) {
body, _ := json.Marshal(loginReq)
resp, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
assert.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
token, ok := result["access_token"].(string)
assert.True(t, ok, "Should have access_token")
@@ -185,7 +203,9 @@ func TestFullApplicationSetup(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
@@ -225,12 +245,15 @@ func TestFullApplicationSetup(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, getUploadPath(), result["folder_path"])
@@ -251,13 +274,16 @@ func TestFullApplicationSetup(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Accept 200 or 202
assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, "success", result["status"])
@@ -276,12 +302,15 @@ func TestFullApplicationSetup(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
data, ok := result["data"].([]interface{})
assert.True(t, ok, "Data field should exist")
+23 -9
View File
@@ -36,7 +36,9 @@ func TestSevenDaySession(t *testing.T) {
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -81,12 +83,15 @@ func TestSevenDaySession(t *testing.T) {
loginResp, err := http.DefaultClient.Do(loginHTTP)
require.NoError(t, err)
defer loginResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(loginResp.Body)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
require.NoError(t, err)
refreshToken, ok := loginResult["refresh_token"].(string)
require.True(t, ok, "Should have refresh_token")
@@ -103,12 +108,15 @@ func TestSevenDaySession(t *testing.T) {
refreshResp, err := http.DefaultClient.Do(refreshHTTP)
require.NoError(t, err)
defer refreshResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(refreshResp.Body)
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
var refreshResult map[string]interface{}
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
require.NoError(t, err)
// Verify ExpiresIn is 7 days
expiresIn, ok := refreshResult["expires_in"].(float64)
@@ -134,7 +142,9 @@ func TestSevenDaySession(t *testing.T) {
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var authResponse struct {
Token string `json:"access_token"`
@@ -230,7 +240,7 @@ func TestNoClientSideCookies(t *testing.T) {
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
_ = resp.Body.Close()
// Accept both 201 (new user) and 409 (already exists from previous run)
require.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict,
@@ -253,7 +263,9 @@ func TestNoClientSideCookies(t *testing.T) {
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
body, err := io.ReadAll(resp2.Body)
require.NoError(t, err)
@@ -277,7 +289,9 @@ func TestNoClientSideCookies(t *testing.T) {
loginResp, err := http.DefaultClient.Do(loginReq)
require.NoError(t, err)
defer loginResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(loginResp.Body)
assert.Equal(t, http.StatusOK, loginResp.StatusCode)
+1 -1
View File
@@ -37,7 +37,7 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test%'")
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
dbPool.Close()
})
+5 -2
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -30,7 +31,7 @@ func TestTagsFilter(t *testing.T) {
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
folderResp, err := client.Do(folderHTTP)
require.NoError(t, err)
folderResp.Body.Close()
_ = folderResp.Body.Close()
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
// Helper to create book with tags
@@ -50,7 +51,9 @@ func TestTagsFilter(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode, "Should create book")
}
+9 -3
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
@@ -98,11 +99,14 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
client := &http.Client{}
resp, err := client.Do(libHTTP)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed")
var libResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResponse)
err = json.NewDecoder(resp.Body).Decode(&libResponse)
require.NoError(t, err)
libraryID := libResponse["id"].(string)
if withFolder {
@@ -117,7 +121,9 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
folderResp, err := client.Do(folderHTTP)
require.NoError(t, err)
defer folderResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(folderResp.Body)
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed")
}
+70 -37
View File
@@ -13,6 +13,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
@@ -76,19 +77,20 @@ 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
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
Token string
RegularToken string
mu sync.Mutex
closed bool
Server *httptest.Server
DB *database.Queries
DBPool *pgxpool.Pool
Config *config.Config
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService
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
@@ -284,6 +286,7 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
{"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 {
@@ -315,12 +318,15 @@ func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Failed to login")
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
token, ok := result["access_token"].(string)
require.True(t, ok, "Should have access_token")
@@ -448,17 +454,21 @@ func setupTestServer(t *testing.T) *TestServerSetup {
connManager := wsync.NewConnectionManager()
cleanupCancel := connManager.StartCleanupTask()
// Create sync queue processor with cancellable context
progressService := wsync.NewProgressService(queries, connManager)
queueProcessor := wsync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService)
queueCtx, queueCancel := context.WithCancel(context.Background())
go queueProcessor.Start(queueCtx)
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
koreaderHandler.SetProgressService(progressService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager)
analyticsHandler := handlers.NewAnalyticsHandler(queries)
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
// Create refactored handlers (matching main.go)
libraryService := services.NewLibraryService(queries)
@@ -469,7 +479,9 @@ func setupTestServer(t *testing.T) *TestServerSetup {
filtersHandler := handlers.NewFiltersHandler(queries)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
mediaHandler.SetProgressService(progressService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS
@@ -514,14 +526,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
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,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
}
@@ -543,11 +558,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
ctx := context.Background()
// Delete ALL test users (any user with test email domains) to ensure clean state
// This handles users created during tests that may have been promoted to admin, etc.
// Delete transient test users but preserve the dev admin user
// testuser@tests.bookhoard.internal is the shared dev admin — deleting it
// triggers ON DELETE SET NULL on libraries.created_by_admin_id
allUsers, err := queries.ListUsers(ctx)
if err == nil {
for _, user := range allUsers {
if user.Email == "testuser@tests.bookhoard.internal" {
continue
}
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
queries.DeleteUser(ctx, user.ID)
}
@@ -601,17 +620,18 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Create TestServerSetup struct with all resources
setup := &TestServerSetup{
Server: ts,
DB: queries,
DBPool: dbPool,
Config: cfg,
ConnManager: connManager,
QueueProcessor: queueProcessor,
CleanupCancel: cleanupCancel,
QueueCtx: queueCtx,
QueueCancel: queueCancel,
Token: adminToken,
RegularToken: regularToken,
Server: ts,
DB: queries,
DBPool: dbPool,
Config: cfg,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
CleanupCancel: cleanupCancel,
QueueCtx: queueCtx,
QueueCancel: queueCancel,
Token: adminToken,
RegularToken: regularToken,
}
// Register cleanup function to run automatically when test completes
@@ -648,12 +668,15 @@ func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password str
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err, "Failed to login")
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
token, ok := result["access_token"].(string)
require.True(t, ok, "Should have access_token")
@@ -698,12 +721,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
resp, err := httpClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResult map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResult)
err = json.NewDecoder(resp.Body).Decode(&libResult)
require.NoError(t, err)
libData := libResult["id"].(string)
@@ -718,7 +744,9 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
folderResp, err := httpClient.Do(folderReqHTTP)
require.NoError(t, err)
defer folderResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(folderResp.Body)
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
mediaItemReq := map[string]interface{}{
@@ -737,12 +765,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
resp2, err := httpClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
require.Equal(t, http.StatusCreated, resp2.StatusCode)
var mediaItemResult map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
err = json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
require.NoError(t, err)
mediaItemID := mediaItemResult["id"].(string)
@@ -771,6 +802,8 @@ func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string,
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
+20 -15
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
@@ -83,7 +84,9 @@ func TestWebSocketDeviceAuth(t *testing.T) {
require.NoError(t, err, "WebSocket connection with device token should succeed")
defer ws.Close()
if resp != nil {
defer resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode, "Should upgrade to WebSocket")
}
// Read initial state message
@@ -116,26 +119,23 @@ func TestWebSocketProgressBroadcast(t *testing.T) {
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
_, _, _ = ws.ReadMessage()
// Update progress via HTTP API
// Update progress via HTTP API to new media-item progress endpoint
progressReq := map[string]interface{}{
"source": "test",
"location": map[string]interface{}{
"percentage": 0.5,
},
"device_metadata": map[string]interface{}{
"device_type": "web",
},
"percentage": 0.5,
"epubcfi": "epubcfi(/6/4/2:10)",
}
body, _ := json.Marshal(progressReq)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/progress/"+mediaID, strings.NewReader(string(body)))
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+mediaID+"/progress", 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 resp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -253,11 +253,14 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
collectionResp, err := client.Do(collectionHTTP)
require.NoError(t, err)
defer collectionResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(collectionResp.Body)
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
var collectionResult map[string]interface{}
json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
require.NoError(t, err)
collectionID := collectionResult["id"].(string)
// Create a test book via API
@@ -289,7 +292,9 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
addResp, err := client.Do(addHTTP)
require.NoError(t, err)
defer addResp.Body.Close()
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(addResp.Body)
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
// Admin should receive collection_updated message
@@ -313,7 +318,7 @@ func connectWebSocketToServer(t *testing.T, serverURL string, token string) *web
ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
require.NoError(t, err, "WebSocket connection should succeed")
if resp != nil {
resp.Body.Close()
_ = resp.Body.Close()
}
require.NotNil(t, ws, "WebSocket connection should be established")
+7 -5
View File
@@ -41,8 +41,9 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
var createLibResponse map[string]interface{}
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
createLibResp.Body.Close()
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
require.NoError(t, err)
_ = createLibResp.Body.Close()
libraryID, ok := createLibResponse["id"].(string)
require.True(t, ok)
@@ -62,7 +63,7 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
resp.Body.Close()
_ = resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
// Create test files in the directory
@@ -141,8 +142,9 @@ func TestWorker_SetFoldersJob(t *testing.T) {
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
var createLibResponse map[string]interface{}
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
createLibResp.Body.Close()
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
require.NoError(t, err)
_ = createLibResp.Body.Close()
libraryID, ok := createLibResponse["id"].(string)
require.True(t, ok)
+6 -2
View File
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS library_types (
INSERT INTO library_types (name, description, allowed_extensions) VALUES
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif'])
ON CONFLICT (name) DO NOTHING;
-- Create users table
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
theme VARCHAR(50) DEFAULT 'tokyo-night',
max_devices INTEGER DEFAULT 10,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
timezone VARCHAR(50) DEFAULT 'UTC',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -47,7 +48,8 @@ CREATE TABLE IF NOT EXISTS system_settings (
-- Insert default system settings
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide')
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
('default_timezone', 'UTC', 'System default timezone')
ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table
@@ -121,6 +123,7 @@ CREATE TABLE IF NOT EXISTS media_items (
google_books_id VARCHAR(100), -- Google Books identifier
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Universal Sync Format Detection
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
@@ -457,6 +460,7 @@ CREATE TABLE IF NOT EXISTS reading_history (
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
+6 -2
View File
@@ -19,9 +19,10 @@ services:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
env_file:
- .env
@@ -57,6 +58,9 @@ services:
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
# System timezone (fallback for server-side time operations)
TZ: ${TZ:-UTC}
ports:
- "8765:8765"
depends_on:
@@ -67,7 +71,7 @@ services:
- bookhoard_conversion_cache:/app/cache/kepub
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
interval: 10s
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+14 -14
View File
@@ -4,20 +4,20 @@ go 1.26.0
require (
github.com/ArcadiaLin/go-epub v0.1.1
github.com/a-h/templ v0.3.1001
github.com/andybalholm/brotli v1.2.0
github.com/a-h/templ v0.3.1020
github.com/andybalholm/brotli v1.2.1
github.com/bodgit/plumbing v1.3.0
github.com/bodgit/windows v1.0.1
github.com/fsnotify/fsnotify v1.9.0
github.com/go-playground/validator/v10 v10.30.1
github.com/go-playground/validator/v10 v10.30.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jackc/pgx/v5 v5.9.1
github.com/jackc/pgx/v5 v5.9.2
github.com/klauspost/compress v1.18.5
github.com/labstack/echo-jwt/v5 v5.0.1
github.com/labstack/echo/v5 v5.0.4
github.com/labstack/echo/v5 v5.1.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3
github.com/pdfcpu/pdfcpu v0.11.1
@@ -26,10 +26,10 @@ require (
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.15
github.com/yuin/goldmark v1.7.17
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
golang.org/x/crypto v0.49.0
golang.org/x/text v0.35.0
golang.org/x/crypto v0.50.0
golang.org/x/text v0.36.0
)
require (
@@ -37,7 +37,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -45,21 +45,21 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/pkcs7 v0.2.0 // indirect
github.com/hhrutter/tiff v1.0.2 // indirect
github.com/hhrutter/tiff v1.0.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/xyproto/randomstring v1.2.0 // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+28 -28
View File
@@ -1,11 +1,11 @@
github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
@@ -32,8 +32,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -50,14 +50,14 @@ github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
@@ -68,12 +68,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v5 v5.0.1 h1:uIpCHCiDPN3jA8Jb47i4EViToUl1uypMiPvVAAgKpIw=
github.com/labstack/echo-jwt/v5 v5.0.1/go.mod h1:kcHmJPzrVSEJa1FRheVoi9EJrBLLUqr1ntlil6uPe1Q=
github.com/labstack/echo/v5 v5.0.4 h1:ll3I/O8BifjMztj9dD1vx/peZQv8cR2CTUdQK6QxGGc=
github.com/labstack/echo/v5 v5.0.4/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrBI=
github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
@@ -110,22 +110,22 @@ github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o
github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+2 -1
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"log"
"net/http"
"os"
@@ -40,7 +41,7 @@ func (a *App) StartServer(addr string) error {
// Start HTTP server in background
go func() {
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if err := a.server.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Server failed to start: %v", err)
}
}()
+4 -1
View File
@@ -55,6 +55,9 @@ func BenchmarkApp_Shutdown(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
app := New(e)
app.Shutdown()
err := app.Shutdown()
if err != nil {
return
}
}
}
+2
View File
@@ -239,6 +239,7 @@ type MediaItems struct {
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"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
FormatGroup string `db:"format_group" json:"format_group"`
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
@@ -507,5 +508,6 @@ type Users struct {
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
+17
View File
@@ -23,6 +23,7 @@ type Querier interface {
// Bulk update format group for all media items
BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context) error
@@ -128,6 +129,7 @@ type Querier interface {
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
// Get collection items
@@ -141,6 +143,7 @@ type Querier interface {
GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error)
// Smart section queries (for system collections)
GetContinueReadingItems(ctx context.Context, arg GetContinueReadingItemsParams) ([]MediaItems, error)
GetContinueSeriesItems(ctx context.Context, arg GetContinueSeriesItemsParams) ([]GetContinueSeriesItemsRow, error)
// ============================================
// CAROUSEL-STYLE DASHBOARD
// ============================================
@@ -166,7 +169,11 @@ type Querier interface {
// Get device shelf mappings
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error)
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error)
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
@@ -177,6 +184,7 @@ type Querier interface {
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error)
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error)
GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([]MediaItems, error)
GetLibraryType(ctx context.Context, id pgtype.UUID) (LibraryTypes, error)
@@ -235,6 +243,8 @@ type Querier interface {
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error)
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
@@ -246,6 +256,7 @@ type Querier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
// System Settings queries
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
GetSystemTimezone(ctx context.Context) (string, error)
// Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
// Get unlinked book by ContentId
@@ -275,7 +286,9 @@ type Querier interface {
IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error)
// Link unlinked book to media item
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
@@ -291,6 +304,8 @@ type Querier interface {
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// Query media items by multiple identifiers with confidence scoring
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
ReassignMediaItems(ctx context.Context, arg ReassignMediaItemsParams) error
// Remove book from collection
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
@@ -316,6 +331,7 @@ type Querier interface {
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
// Set system config
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
@@ -370,6 +386,7 @@ type Querier interface {
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
File diff suppressed because it is too large Load Diff
+139 -32
View File
@@ -27,6 +27,7 @@ SELECT
u.max_devices,
u.created_at,
u.updated_at,
u.timezone,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -60,6 +61,9 @@ SELECT * FROM library_types WHERE id = $1;
-- name: GetLibraryTypeByName :one
SELECT * FROM library_types WHERE name = $1;
-- name: SyncLibraryTypeExtensions :exec
UPDATE library_types SET allowed_extensions = $2 WHERE name = $1;
-- Libraries queries
-- name: CreateLibrary :one
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
@@ -104,6 +108,13 @@ SELECT lf.library_id, l.* FROM library_folders lf
JOIN libraries l ON lf.library_id = l.id
WHERE lf.folder_path = $1;
-- name: GetLibraryByFolderPathPrefix :one
SELECT lf.library_id, lf.folder_path, l.* FROM library_folders lf
JOIN libraries l ON lf.library_id = l.id
WHERE $1 LIKE lf.folder_path || '%'
ORDER BY LENGTH(lf.folder_path) DESC
LIMIT 1;
-- Library Visibility queries
-- name: SetLibraryVisibility :one
INSERT INTO library_visibility (user_id, library_id, is_visible)
@@ -128,8 +139,8 @@ ORDER BY l.created_at ASC;
-- Media Items queries
-- name: CreateMediaItem :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, 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)
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)
RETURNING *;
-- name: GetMediaItem :one
@@ -248,6 +259,20 @@ UPDATE media_items SET
goodreads_id = $21,
openlibrary_id = $22,
google_books_id = $23,
manga_type = $24,
reading_direction = $25,
series_count = $26,
volume = $27,
imprint = $28,
age_rating = $29,
web_url = $30,
metadata_notes = $31,
community_rating = $32,
story_arc = $33,
is_black_and_white = $34,
alternate_info = $35,
scan_information = $36,
summary = $37,
updated_at = NOW()
WHERE id = $1
RETURNING *;
@@ -300,9 +325,33 @@ RETURNING *;
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, role;
-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
-- name: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_id = $1;
-- name: GetFirstAdminExclude :one
SELECT id, email, username, theme, first_name, last_name, role, max_devices, created_at, updated_at FROM users
WHERE role = 'admin' AND id != $1
ORDER BY created_at ASC
LIMIT 1;
-- name: GetFirstAdmin :one
SELECT id FROM users
WHERE role = 'admin'
ORDER BY created_at ASC
LIMIT 1;
-- name: ReassignLibraries :exec
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
-- name: ReassignMediaItems :exec
UPDATE media_items SET added_by_admin_id = $2 WHERE added_by_admin_id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
@@ -1099,19 +1148,19 @@ RETURNING *;
-- name: DeleteSyncConflict :exec
DELETE FROM sync_conflicts WHERE id = $1;
-- name: ListAllConflictsByUserAndStatus :many
SELECT sc.*, mi.title, mi.author
FROM sync_conflicts sc
JOIN media_items mi ON sc.media_item_id = mi.id
WHERE sc.user_id = $1 AND sc.resolution_status = $2
ORDER BY sc.created_at DESC;
-- name: ListAllConflictsByUserAndStatus :many
SELECT sc.*, mi.title, mi.author
FROM sync_conflicts sc
JOIN media_items mi ON sc.media_item_id = mi.id
WHERE sc.user_id = $1 AND sc.resolution_status = $2
ORDER BY sc.created_at DESC;
-- name: ListConflictsByUser :many
SELECT sc.*, mi.title, mi.author
FROM sync_conflicts sc
JOIN media_items mi ON sc.media_item_id = mi.id
WHERE sc.user_id = $1
ORDER BY sc.created_at DESC;
-- name: ListConflictsByUser :many
SELECT sc.*, mi.title, mi.author
FROM sync_conflicts sc
JOIN media_items mi ON sc.media_item_id = mi.id
WHERE sc.user_id = $1
ORDER BY sc.created_at DESC;
-- ============================================
-- KOREADER SYNC PROTOCOL
@@ -1216,7 +1265,7 @@ WHERE lv.user_id = $1
ORDER BY mi.title ASC
LIMIT 1000;
-- name: CheckForProgressConflicts :one
-- name: CheckForProgressConflicts :one
SELECT COUNT(*) as conflict_count
FROM reading_progress
WHERE media_item_id = $1
@@ -1781,7 +1830,7 @@ LIMIT $1 OFFSET $2;
-- Dashboard preferences queries
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = $1 AND library_id = $2;
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR 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)
@@ -1845,59 +1894,112 @@ SELECT mi.* FROM media_items mi
INNER JOIN (
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
FROM reading_progress
WHERE user_id = $2
WHERE user_id = sqlc.narg('user_id')
AND percentage > 0
AND percentage < 1
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
ORDER BY rp.last_read_at DESC
LIMIT $3;
LIMIT sqlc.narg('limit');
-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC
LIMIT $2;
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
LIMIT sqlc.narg('limit');
-- name: GetRecentlyReadItems :many
SELECT mi.* FROM media_items mi
INNER JOIN (
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
FROM reading_progress
WHERE user_id = $2
WHERE user_id = sqlc.narg('user_id')
AND percentage >= 1
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
ORDER BY rp.last_read_at DESC
LIMIT $3;
LIMIT sqlc.narg('limit');
-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id
AND rp.user_id = $2
AND rp.user_id = sqlc.narg('user_id')
AND rp.percentage > 0
)
ORDER BY mi.created_at DESC
LIMIT $3;
LIMIT sqlc.narg('limit');
-- name: GetCollectionItemsForDashboard :many
SELECT mi.*, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.library_id = $2
WHERE ci.collection_id = sqlc.narg('collection_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
ORDER BY ci.added_at DESC
LIMIT $3;
LIMIT sqlc.narg('limit');
-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many
SELECT series, COUNT(*) as book_count,
MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != ''
GROUP BY series
ORDER BY MAX(created_at) DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT series)::int
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != '';
-- name: GetSeriesCovers :many
SELECT cover_image_path, library_id
FROM media_items
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
ORDER BY series_number ASC NULLS LAST
LIMIT sqlc.narg('limit');
-- name: GetSeriesBooks :many
SELECT * FROM media_items
WHERE series = sqlc.narg('series')
ORDER BY series_number ASC NULLS LAST;
-- name: GetContinueSeriesItems :many
WITH user_series_progress AS (
SELECT mi.series,
MAX(mi.series_number) as max_read_number,
MAX(rp.last_read_at) as last_read_at
FROM reading_progress rp
JOIN media_items mi ON mi.id = rp.media_item_id
WHERE rp.user_id = sqlc.narg('user_id')
AND rp.percentage > 0
AND mi.series IS NOT NULL AND mi.series != ''
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
GROUP BY mi.series
),
next_books AS (
SELECT DISTINCT ON (mi.series) mi.*,
usp.last_read_at
FROM media_items mi
JOIN user_series_progress usp ON mi.series = usp.series
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
ORDER BY mi.series, mi.series_number ASC NULLS LAST
)
SELECT * FROM next_books
ORDER BY last_read_at DESC NULLS LAST
LIMIT sqlc.narg('limit');
-- name: GetSavedFilters :many
SELECT * FROM saved_filters
WHERE user_id = @user_id AND resource_type = @resource_type
@@ -2093,3 +2195,8 @@ SELECT
FROM libraries l
JOIN library_types lt ON l.library_type_id = lt.id
WHERE l.id = $1;
-- name: GetBooksByTag :many
SELECT * FROM media_items
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
ORDER BY title ASC;
+6 -4
View File
@@ -7,13 +7,15 @@ import (
"os"
"strings"
"bookhoard/templates"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"bookhoard/templates"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type DocsHandler struct {
@@ -77,7 +79,7 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
// Convert markdown to HTML
var buf bytes.Buffer
context := parser.NewContext()
if err := h.markdown.Convert([]byte(content), &buf, parser.WithContext(context)); err != nil {
if err := h.markdown.Convert(content, &buf, parser.WithContext(context)); err != nil {
return nil, fmt.Errorf("failed to convert markdown: %w", err)
}
@@ -169,7 +171,7 @@ func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbI
// Don't add the last part (current page)
if i < len(parts)-1 {
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
Title: strings.Title(strings.ReplaceAll(part, "-", " ")),
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
URL: "/docs" + path,
})
}
+4 -1
View File
@@ -5,6 +5,9 @@ import (
"strings"
"bookhoard/templates"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// BuildNavigation creates the navigation structure from the docs filesystem
@@ -100,7 +103,7 @@ func (h *DocsHandler) getDocTitle(docPath string) string {
filename := parts[len(parts)-1]
// Convert to title case
title = strings.Title(strings.ReplaceAll(filename, "-", " "))
title = cases.Title(language.English).String(strings.ReplaceAll(filename, "-", " "))
// Handle special cases
switch filename {
+12 -11
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"net/http"
"time"
@@ -74,18 +75,18 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
endDate := c.QueryParam("end_date")
if startDate == "" {
startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
startDate = time.Now().AddDate(0, -1, 0).Format("01-02-2006")
}
if endDate == "" {
endDate = time.Now().Format("2006-01-02")
endDate = time.Now().Format("01-02-2006")
}
startTime, err := time.Parse("2006-01-02", startDate)
startTime, err := time.Parse("01-02-2006", startDate)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
}
endTime, err := time.Parse("2006-01-02", endDate)
endTime, err := time.Parse("01-02-2006", endDate)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
}
@@ -98,7 +99,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
CreatedAt_2: pgtype.Timestamptz{Time: endTime, Valid: true},
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
}
@@ -129,7 +130,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
longestSession = int(minutes)
}
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
if dailyMap[dateKey] == nil {
dailyMap[dateKey] = &DailyReading{
Date: dateKey,
@@ -140,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
if entry.PagesRead.Valid {
totalPages += int(entry.PagesRead.Int32)
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
if dailyMap[dateKey] != nil {
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
}
@@ -211,7 +212,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
ctx := context.Background()
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get device usage")
}
@@ -221,7 +222,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
lastSync := ""
if u.LastSync != nil {
if t, ok := u.LastSync.(time.Time); ok {
lastSync = t.Format("2006-01-02 15:04:05")
lastSync = t.Format("01-02-2006 03:04:05 PM")
}
}
@@ -263,7 +264,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
Limit: limitInt,
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get popular books")
}
@@ -273,7 +274,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
lastRead := ""
if book.LastRead != nil {
if t, ok := book.LastRead.(time.Time); ok {
lastRead = t.Format("2006-01-02 15:04:05")
lastRead = t.Format("01-02-2006 03:04:05 PM")
}
}
+43 -7
View File
@@ -7,6 +7,7 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"context"
"errors"
"fmt"
"net/http"
"os"
@@ -86,6 +87,7 @@ type UpdateProfileRequest struct {
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
}
type AdminUpdateUserRequest struct {
@@ -94,6 +96,7 @@ type AdminUpdateUserRequest struct {
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
}
@@ -261,7 +264,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
}
c.SetCookie(cookie)
_, refreshToken, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
@@ -407,7 +410,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
}
c.SetCookie(cookie)
_, refreshToken, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
@@ -495,7 +498,7 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
} else {
// Self-edit mode
targetUserUUID = currentUser.ID
@@ -563,6 +566,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Update timezone
if req.Timezone != "" {
if _, err := time.LoadLocation(req.Timezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid timezone",
})
}
err := h.db.UpdateUserTimezone(c.Request().Context(), database.UpdateUserTimezoneParams{
ID: targetUserUUID,
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
})
if err != nil {
return err
}
}
// Update email (if provided)
if req.Email != "" {
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
@@ -759,7 +778,7 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
} else {
// Self-change mode
targetUserUUID = currentUser.ID
@@ -839,7 +858,7 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
} else {
// Self-deletion mode
targetUserUUID = currentUser.ID
@@ -884,10 +903,26 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last admin account"})
}
// If deleting an admin, reassign their libraries and media items to another admin
// before deletion to prevent ON DELETE SET NULL from orphaning ownership
if targetUserRole == "admin" {
successor, err := h.db.GetFirstAdminExclude(c.Request().Context(), targetUserUUID)
if err == nil {
_ = h.db.ReassignLibraries(c.Request().Context(), database.ReassignLibrariesParams{
CreatedByAdminID: targetUserUUID,
CreatedByAdminID_2: successor.ID,
})
_ = h.db.ReassignMediaItems(c.Request().Context(), database.ReassignMediaItemsParams{
AddedByAdminID: targetUserUUID,
AddedByAdminID_2: successor.ID,
})
}
}
// Delete user (this will cascade to delete all related data)
err = h.db.DeleteUser(c.Request().Context(), targetUserUUID)
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusNotFound, `<div class="text-red-500">User not found</div>`)
}
@@ -940,7 +975,7 @@ func (h *AuthHandler) UpdateUserMaxDevices(c *echo.Context) error {
MaxDevices: pgtype.Int4{Int32: req.MaxDevices, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -996,6 +1031,7 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
{"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 {
+110 -18
View File
@@ -6,6 +6,7 @@ import (
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
@@ -137,6 +138,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
includeAuto := c.QueryParam("include_auto") == "true"
sortBy := c.QueryParam("sort_by")
libraryID := c.QueryParam("library_id")
collections, err := h.GetCollectionsData(c)
if err != nil {
@@ -151,6 +153,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
Icon string `json:"icon"`
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
CreatedAt string `json:"created_at"`
BookCount int `json:"book_count"`
}
response := make([]CollectionResponse, 0, len(collections))
@@ -158,14 +161,35 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
if !includeAuto && len(col.AutoAssignRules) > 0 {
continue
}
bookCount := 0
if libraryID != "" {
libUUID, libErr := uuid.Parse(libraryID)
if libErr == nil {
items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: pgtype.Int4{Int32: 10000, Valid: true},
})
if countErr == nil {
for _, item := range items {
if !item.Excluded.Valid || !item.Excluded.Bool {
bookCount++
}
}
}
}
}
response = append(response, CollectionResponse{
ID: uuid.UUID(col.ID.Bytes),
ID: col.ID.Bytes,
Name: col.Name,
Description: textToString(col.Description),
Color: textToString(col.Color),
Icon: textToString(col.Icon),
AutoAssignRules: json.RawMessage(col.AutoAssignRules),
AutoAssignRules: col.AutoAssignRules,
CreatedAt: col.CreatedAt.Time.String(),
BookCount: bookCount,
})
}
@@ -196,24 +220,92 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
books, err := h.GetCollectionBooksData(c, collectionID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
libraryID := c.QueryParam("library_id")
var bookList []BookInfo
var libUUID pgtype.UUID
if libraryID != "" {
parsed, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
bookList := make([]BookInfo, 0, len(books))
for _, book := range books {
bookList = append(bookList, BookInfo{
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
})
if collection.QueryType.Valid && collection.QueryType.String != "" {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
dashboardSvc := services.NewDashboardService(h.db)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
}
for _, section := range sections {
if section.CollectionID == collectionID {
bookCards := make([]BookInfo, len(section.Items))
for i, item := range section.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
bookList = bookCards
break
}
}
} else if libUUID.Valid {
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
LibraryID: libUUID,
Limit: pgtype.Int4{Int32: 10000, Valid: true},
})
if collErr != nil {
bookList = []BookInfo{}
} else {
var validItems []database.GetCollectionItemsForDashboardRow
for _, item := range collItems {
if !item.Excluded.Valid || !item.Excluded.Bool {
validItems = append(validItems, item)
}
}
bookCards := make([]BookInfo, len(validItems))
for i, item := range validItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
bookList = bookCards
}
} else {
books, booksErr := h.GetCollectionBooksData(c, collectionID)
if booksErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()})
}
bookList = make([]BookInfo, 0, len(books))
for _, book := range books {
bookList = append(bookList, BookInfo{
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
})
}
}
var viewSettings map[string]interface{}
if len(collection.ViewSettings) > 0 {
json.Unmarshal(collection.ViewSettings, &viewSettings)
err := json.Unmarshal(collection.ViewSettings, &viewSettings)
if err != nil {
return err
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
@@ -459,8 +551,8 @@ func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
response := make([]MappingResponse, 0, len(mappings))
for _, m := range mappings {
response = append(response, MappingResponse{
ID: uuid.UUID(m.ID.Bytes),
CollectionID: uuid.UUID(m.CollectionID.Bytes),
ID: m.ID.Bytes,
CollectionID: m.CollectionID.Bytes,
CollectionName: m.CollectionName,
DeviceShelfName: textToString(m.DeviceShelfName),
SyncDirection: textToString(m.SyncDirection),
@@ -585,7 +677,7 @@ func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
response := make([]CollectionResponse, 0, len(collections))
for _, col := range collections {
response = append(response, CollectionResponse{
ID: uuid.UUID(col.ID.Bytes),
ID: col.ID.Bytes,
Name: col.Name,
Description: textToString(col.Description),
Color: textToString(col.Color),
@@ -891,7 +983,7 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
+20 -11
View File
@@ -5,6 +5,7 @@ import (
wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"errors"
"net/http"
"time"
@@ -83,7 +84,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
}
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, 0, err
}
@@ -150,7 +151,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
@@ -214,7 +215,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
@@ -292,7 +293,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
@@ -316,8 +317,12 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
}
currentPage := existingProgress.CurrentPage
totalPages := existingProgress.TotalPages
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
@@ -376,7 +381,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
@@ -396,7 +401,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflicts, err := h.db.ListSyncConflictsByUser(context.Background(), user.ID)
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
}
@@ -637,7 +642,7 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
@@ -661,8 +666,12 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
}
currentPage := existingProgress.CurrentPage
totalPages := existingProgress.TotalPages
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
+309
View File
@@ -0,0 +1,309 @@
package handlers
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGetMostRecentSource_KOReaderMoreRecent(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.45,
"epubcfi": "epubcfi(/6/4/2:15)",
},
},
"kobo": {
Source: "kobo",
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.42,
"page": float64(89),
},
},
}
source, data := handler.getMostRecentSource(conflictData)
assert.Equal(t, "koreader", source)
assert.Equal(t, 0.45, data["percentage"])
}
func TestGetMostRecentSource_KoboMoreRecent(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.45,
},
},
"kobo": {
Source: "kobo",
Timestamp: time.Date(2026, 1, 30, 20, 15, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.42,
},
},
}
source, data := handler.getMostRecentSource(conflictData)
assert.Equal(t, "kobo", source)
assert.Equal(t, 0.42, data["percentage"])
}
func TestGetMostRecentSource_SingleSource(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.50,
},
},
}
source, data := handler.getMostRecentSource(conflictData)
assert.Equal(t, "koreader", source)
assert.Equal(t, 0.50, data["percentage"])
}
func TestGetMostRecentSource_SameTimestamp(t *testing.T) {
handler := &ConflictHandler{}
ts := time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC)
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Timestamp: ts,
Data: map[string]interface{}{
"percentage": 0.45,
},
},
"kobo": {
Source: "kobo",
Timestamp: ts,
Data: map[string]interface{}{
"percentage": 0.42,
},
},
}
source, _ := handler.getMostRecentSource(conflictData)
assert.Contains(t, []string{"koreader", "kobo"}, source)
}
func TestGetMostRecentSource_EmptyData(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{}
source, data := handler.getMostRecentSource(conflictData)
assert.Equal(t, "", source)
assert.Nil(t, data)
}
func TestGetHighestProgressSource_KOReaderHigher(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Data: map[string]interface{}{
"percentage": 0.75,
},
},
"kobo": {
Source: "kobo",
Data: map[string]interface{}{
"percentage": 0.42,
},
},
}
source, data := handler.getHighestProgressSource(conflictData)
assert.Equal(t, "koreader", source)
assert.Equal(t, 0.75, data["percentage"])
}
func TestGetHighestProgressSource_KoboHigher(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Data: map[string]interface{}{
"percentage": 0.30,
},
},
"kobo": {
Source: "kobo",
Data: map[string]interface{}{
"percentage": 0.90,
},
},
}
source, data := handler.getHighestProgressSource(conflictData)
assert.Equal(t, "kobo", source)
assert.Equal(t, 0.90, data["percentage"])
}
func TestGetHighestProgressSource_SingleSource(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Data: map[string]interface{}{
"percentage": 0.50,
},
},
}
source, data := handler.getHighestProgressSource(conflictData)
assert.Equal(t, "koreader", source)
assert.Equal(t, 0.50, data["percentage"])
}
func TestGetHighestProgressSource_EmptyData(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{}
source, data := handler.getHighestProgressSource(conflictData)
assert.Equal(t, "", source)
assert.Nil(t, data)
}
func TestGetHighestProgressSource_NoPercentageField(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Data: map[string]interface{}{},
},
}
source, data := handler.getHighestProgressSource(conflictData)
assert.Equal(t, "", source)
assert.Nil(t, data)
}
func TestGetHighestProgressSource_MixedPercentageTypes(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Data: map[string]interface{}{
"percentage": 0.60,
},
},
"kobo": {
Source: "kobo",
Data: map[string]interface{}{
"percentage": float64(0.80),
},
},
}
source, data := handler.getHighestProgressSource(conflictData)
assert.Equal(t, "kobo", source)
assert.Equal(t, float64(0.80), data["percentage"])
}
func TestGetHighestProgressSource_BothZero(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Data: map[string]interface{}{
"percentage": 0.0,
},
},
"kobo": {
Source: "kobo",
Data: map[string]interface{}{
"percentage": 0.0,
},
},
}
source, data := handler.getHighestProgressSource(conflictData)
assert.NotEmpty(t, source)
assert.NotNil(t, data)
assert.Equal(t, 0.0, data["percentage"])
}
func TestGetHighestProgressSource_ThreeWayConflict(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Data: map[string]interface{}{
"percentage": 0.45,
},
},
"kobo": {
Source: "kobo",
Data: map[string]interface{}{
"percentage": 0.92,
},
},
"web": {
Source: "web",
Data: map[string]interface{}{
"percentage": 0.70,
},
},
}
source, data := handler.getHighestProgressSource(conflictData)
assert.Equal(t, "kobo", source)
assert.Equal(t, 0.92, data["percentage"])
}
func TestGetMostRecentSource_ThreeWayConflict(t *testing.T) {
handler := &ConflictHandler{}
conflictData := map[string]ConflictSourceData{
"koreader": {
Source: "koreader",
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.45,
},
},
"kobo": {
Source: "kobo",
Timestamp: time.Date(2026, 1, 30, 20, 20, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.92,
},
},
"web": {
Source: "web",
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
Data: map[string]interface{}{
"percentage": 0.70,
},
},
}
source, data := handler.getMostRecentSource(conflictData)
assert.Equal(t, "kobo", source)
assert.Equal(t, 0.92, data["percentage"])
}
+15 -12
View File
@@ -30,12 +30,13 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
@@ -133,6 +134,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
"Recently Added": true,
"Recently Read": true,
"Not Started": true,
"Continue Series": true,
}
if !validCollections[req.CollectionName] {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
@@ -201,14 +203,15 @@ func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
// Return default preferences instead of 404 when none exist
+10 -10
View File
@@ -257,8 +257,8 @@ func (h *DeviceHandler) ListDevices(c *echo.Context) error {
ID: device.ID.Bytes,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
LastSync: (*time.Time)(&device.LastSync.Time),
LastSeen: (*time.Time)(&device.LastSeen.Time),
LastSync: &device.LastSync.Time,
LastSeen: &device.LastSeen.Time,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
@@ -301,8 +301,8 @@ func (h *DeviceHandler) GetDevicesData(c *echo.Context) ([]DeviceInfo, error) {
ID: device.ID.Bytes,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
LastSync: (*time.Time)(&device.LastSync.Time),
LastSeen: (*time.Time)(&device.LastSeen.Time),
LastSync: &device.LastSync.Time,
LastSeen: &device.LastSeen.Time,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
@@ -353,8 +353,8 @@ func (h *DeviceHandler) GetDevice(c *echo.Context) error {
ID: device.ID.Bytes,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
LastSync: (*time.Time)(&device.LastSync.Time),
LastSeen: (*time.Time)(&device.LastSeen.Time),
LastSync: &device.LastSync.Time,
LastSeen: &device.LastSeen.Time,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
@@ -447,8 +447,8 @@ func (h *DeviceHandler) UpdateDevice(c *echo.Context) error {
ID: updatedDevice.ID.Bytes,
DeviceName: updatedDevice.DeviceName,
DeviceType: updatedDevice.DeviceType,
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
LastSync: &updatedDevice.LastSync.Time,
LastSeen: &updatedDevice.LastSeen.Time,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq, // Now correctly returns the updated value
@@ -566,8 +566,8 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
ID: updatedDevice.ID.Bytes,
DeviceName: updatedDevice.DeviceName,
DeviceType: updatedDevice.DeviceType,
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
LastSync: &updatedDevice.LastSync.Time,
LastSeen: &updatedDevice.LastSeen.Time,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
+1 -1
View File
@@ -77,7 +77,7 @@ func (h *FiltersHandler) GetSavedFilters(c *echo.Context) error {
ID: uuid.Must(uuid.FromBytes(f.ID.Bytes[:])),
Name: f.Name,
ResourceType: f.ResourceType,
Filters: json.RawMessage(f.Filters), // Return JSONB as-is
Filters: f.Filters,
CreatedAt: f.CreatedAt.Time.Format(time.RFC3339),
UpdatedAt: f.UpdatedAt.Time.Format(time.RFC3339),
}
+95 -86
View File
@@ -3,9 +3,6 @@ package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"regexp"
@@ -20,12 +17,17 @@ import (
type KoboHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
}
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
return &KoboHandler{db: db, connManager: connManager}
}
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc
}
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
// Enhanced Kobo Sync - ContentId Mapping Logic
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
@@ -33,7 +35,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
catalog, err := h.db.GetDeviceCatalogByKoboContentId(ctx.Request().Context(), contentId)
if err == nil && catalog.ID.Valid {
// Found! Use canonical Bookhoard UUID
return uuid.UUID(catalog.BookhoardUuid.Bytes), nil, "catalog_match"
return catalog.BookhoardUuid.Bytes, nil, "catalog_match"
}
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
@@ -52,7 +54,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
DeliveryMethod: pgtype.Text{String: "sync", Valid: true},
})
return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match"
return mediaItem.ID.Bytes, nil, "sha256_match"
}
}
@@ -175,12 +177,6 @@ func looksLikeSHA256(s string) bool {
return matched
}
// calculateFileSHA256 calculates SHA-256 hash of file path
func calculateFileSHA256(filePath string) string {
hash := sha256.Sum256([]byte(filePath))
return hex.EncodeToString(hash[:])
}
type KoboDeviceInfo struct {
DeviceID string `json:"DeviceId"`
Model string `json:"Model"`
@@ -303,7 +299,8 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
}
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
pagesRemaining = new(int(progress.TotalPages.Int32 - progress.CurrentPage.Int32))
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
pagesRemaining = &remaining
}
}
@@ -402,39 +399,38 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
unlinkedBooks := 0
for _, readingSync := range req.ReadingSync {
// Use ContentId mapping with fallback logic
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
if err != nil || bookhoardUUID == uuid.Nil {
// Unlinked book detected
unlinkedBooks++
// TODO: Create unlinked book entry for manual resolution
continue
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
percentage := readingSync.PercentRead / 100.0
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
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,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
}
if err == nil {
markupsSynced++
h.connManager.BroadcastProgressUpdate(
bookhoardUUID,
percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID).String(),
Name: device.DeviceName,
Type: "kobo",
},
)
}
}
@@ -480,15 +476,33 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
epubcfi = strings.TrimSuffix(epubcfi, ")")
}
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
chapter := bookmarkSync.Chapter
chapterProgress := 0.5
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},
Epubcfi: &epubcfi,
Chapter: &chapter,
ChapterProgress: &chapterProgress,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: false,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
}
if err != nil {
fmt.Printf("Failed to store last-read-place: %v", err)
}
@@ -605,34 +619,33 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
}
for _, test := range req {
// Use ContentId mapping with fallback logic
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
if err != nil || bookhoardUUID == uuid.Nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
percentage := test.PercentRead / 100.0
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
if err == nil {
h.connManager.BroadcastProgressUpdate(
bookhoardUUID,
percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID).String(),
Name: device.DeviceName,
Type: "kobo",
},
)
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,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
}
}
@@ -648,20 +661,6 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
})
}
func parseKoboDeviceHeader(c *echo.Context) (KoboDeviceInfo, error) {
deviceHeader := c.Request().Header.Get("x-kobo-device")
if deviceHeader == "" {
return KoboDeviceInfo{}, fmt.Errorf("missing x-kobo-device header")
}
var device KoboDeviceInfo
if err := json.Unmarshal([]byte(deviceHeader), &device); err != nil {
return KoboDeviceInfo{}, fmt.Errorf("invalid device header format")
}
return device, nil
}
func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
@@ -682,24 +681,34 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
highlightsSent := 0
for _, syncData := range req {
// Use ContentId mapping with fallback logic
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
if err != nil || bookhoardUUID == uuid.Nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
percentage := syncData.PercentRead / 100.0
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
})
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,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
})
}
if err == nil {
booksSynced++
+72 -140
View File
@@ -3,13 +3,11 @@ package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
@@ -18,12 +16,17 @@ type KOReaderHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor
progressSvc *wsync.ProgressService
}
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
}
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc
}
type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"`
@@ -191,7 +194,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
continue
}
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
if err == nil {
booksSynced++
}
@@ -393,68 +396,46 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
return h.queue.EnqueueProgress(update)
}
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
ctx := c.Request().Context()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
deviceInfo := book.DeviceInfo
deviceModel := deviceInfo.DeviceModel
if deviceModel == "" {
deviceModel = "KOReader Device"
}
if err != nil && err != pgx.ErrNoRows {
if h.progressSvc != nil {
saveReq := wsync.SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
Source: "koreader",
DeviceID: deviceID,
Percentage: &book.Percentage,
Epubcfi: book.Epubcfi,
Chapter: book.Chapter,
CharacterOffset: book.Character,
CurrentPage: book.Page,
TotalPages: book.TotalPages,
DeviceType: "koreader",
DeviceName: deviceModel,
Broadcast: true,
}
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
return err
}
hasExistingProgress := err != pgx.ErrNoRows
conflictDetected := false
if hasExistingProgress && existingProgress.LastSyncSource.Valid {
if existingProgress.LastSyncSource.String != "koreader" && existingProgress.LastSyncTimestamp.Valid {
timeDiff := time.Since(existingProgress.LastSyncTimestamp.Time)
if timeDiff < 5*time.Minute {
percentageDiff := book.Percentage - existingProgress.Percentage.Float64
if percentageDiff < 0 {
percentageDiff = -percentageDiff
}
if percentageDiff > 0.01 {
conflictDetected = true
}
}
}
}
var epubcfi pgtype.Text
var chapter pgtype.Int4
var characterOffset pgtype.Int8
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if book.Epubcfi != nil {
epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true}
}
if book.Chapter != nil {
chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true}
}
if book.Character != nil {
characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true}
}
if book.Page != nil {
currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true}
}
if book.TotalPages != nil {
totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
Epubcfi: textPtrToPgText(book.Epubcfi),
Chapter: intPtrToPgInt4(book.Chapter),
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
CharacterOffset: int64PtrToPgInt8(book.Character),
CurrentPage: intPtrToPgInt4(book.Page),
TotalPages: intPtrToPgInt4(book.TotalPages),
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
ViewportY: pgtype.Float8{},
@@ -464,97 +445,42 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.U
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
if err != nil {
return err
}
if conflictDetected {
koreaderData := map[string]interface{}{
"source": "koreader",
"timestamp": time.Now(),
"data": map[string]interface{}{
"percentage": book.Percentage,
},
}
if book.Epubcfi != nil {
koreaderData["data"].(map[string]interface{})["epubcfi"] = *book.Epubcfi
}
if book.Chapter != nil {
koreaderData["data"].(map[string]interface{})["chapter"] = *book.Chapter
}
if book.Character != nil {
koreaderData["data"].(map[string]interface{})["character"] = *book.Character
}
if book.Page != nil {
koreaderData["data"].(map[string]interface{})["page"] = *book.Page
}
if book.TotalPages != nil {
koreaderData["data"].(map[string]interface{})["total_pages"] = *book.TotalPages
}
existingData := map[string]interface{}{
"source": existingProgress.LastSyncSource.String,
"timestamp": existingProgress.LastSyncTimestamp.Time,
"data": map[string]interface{}{
"percentage": existingProgress.Percentage.Float64,
},
}
if existingProgress.Epubcfi.Valid {
existingData["data"].(map[string]interface{})["epubcfi"] = existingProgress.Epubcfi.String
}
if existingProgress.Chapter.Valid {
existingData["data"].(map[string]interface{})["chapter"] = existingProgress.Chapter.Int32
}
if existingProgress.CharacterOffset.Valid {
existingData["data"].(map[string]interface{})["character"] = existingProgress.CharacterOffset.Int64
}
if existingProgress.CurrentPage.Valid {
existingData["data"].(map[string]interface{})["page"] = existingProgress.CurrentPage.Int32
}
if existingProgress.TotalPages.Valid {
existingData["data"].(map[string]interface{})["total_pages"] = existingProgress.TotalPages.Int32
}
conflictData := map[string]interface{}{
"koreader": koreaderData,
"existing": existingData,
}
conflictDataJSON, _ := json.Marshal(conflictData)
_, err := h.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
MediaItemID: mediaItemID,
UserID: userID,
ConflictType: "progress",
ConflictData: conflictDataJSON,
})
if err == nil {
h.connManager.BroadcastConflictNotification(
mediaItemID.Bytes,
"detection",
"",
)
}
}
deviceInfo := book.DeviceInfo
if deviceInfo.DeviceModel == "" {
deviceInfo.DeviceModel = "KOReader Device"
}
h.connManager.BroadcastProgressUpdate(
uuid.UUID(mediaItemID.Bytes),
mediaItemID.Bytes,
book.Percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID.Bytes).String(),
Name: deviceInfo.DeviceModel,
ID: uuid.UUID(deviceID.Bytes).String(),
Name: deviceModel,
Type: "koreader",
},
)
_, err = h.db.UpdateDeviceLastSync(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: false})
return nil
}
return err
func textPtrToPgText(s *string) pgtype.Text {
if s != nil {
return pgtype.Text{String: *s, Valid: true}
}
return pgtype.Text{}
}
func intPtrToPgInt4(i *int) pgtype.Int4 {
if i != nil {
return pgtype.Int4{Int32: int32(*i), Valid: true}
}
return pgtype.Int4{}
}
func int64PtrToPgInt8(i *int64) pgtype.Int8 {
if i != nil {
return pgtype.Int8{Int64: *i, Valid: true}
}
return pgtype.Int8{}
}
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
@@ -601,19 +527,24 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
progressData.Epubcfi = &progress.Epubcfi.String
}
if progress.Chapter.Valid {
progressData.Chapter = new(int(progress.Chapter.Int32))
progress := int(progress.Chapter.Int32)
progressData.Chapter = &progress
}
if progress.ChapterProgress.Valid {
progressData.ChapterProgress = new(progress.ChapterProgress.Float64)
progress := progress.ChapterProgress.Float64
progressData.ChapterProgress = &progress
}
if progress.CharacterOffset.Valid {
progressData.Character = new(int64(progress.CharacterOffset.Int64))
progress := progress.CharacterOffset.Int64
progressData.Character = &progress
}
if progress.CurrentPage.Valid {
progressData.Page = new(int(progress.CurrentPage.Int32))
progress := int(progress.CurrentPage.Int32)
progressData.Page = &progress
}
if progress.TotalPages.Valid {
progressData.TotalPages = new(int(progress.TotalPages.Int32))
progress := int(progress.TotalPages.Int32)
progressData.TotalPages = &progress
}
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
@@ -693,7 +624,8 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
if err == nil {
percentRead = progress.Percentage.Float64
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
pagesRemaining = new(int(progress.TotalPages.Int32 - progress.CurrentPage.Int32))
pages := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
pagesRemaining = &pages
}
if progress.LastReadAt.Valid {
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
+1 -1
View File
@@ -320,7 +320,7 @@ func parseUUID(uuidStr string) (pgtype.UUID, error) {
if err != nil {
return pgtype.UUID{}, err
}
return pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}, nil
return pgtype.UUID{Bytes: parsedUUID, Valid: true}, nil
}
// GetUserVisibleLibrariesData returns libraries for SSR (not JSON response)
+260 -48
View File
@@ -3,13 +3,16 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
@@ -60,33 +63,41 @@ type CreateMediaItemRequest struct {
// UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags []string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors []string `json:"contributors"`
// NEW: Reading direction and comic metadata fields
MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'
ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical'
SeriesCount int32 `json:"series_count"`
Volume int32 `json:"volume"`
Imprint string `json:"imprint"`
AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult'
WebURL string `json:"web_url"`
MetadataNotes string `json:"metadata_notes"`
CommunityRating float64 `json:"community_rating"`
StoryArc string `json:"story_arc"`
IsBlackAndWhite bool `json:"is_black_and_white"`
AlternateInfo string `json:"alternate_info"` // JSON string
ScanInformation string `json:"scan_information"`
Summary string `json:"summary"`
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"`
ISBN string `form:"isbn" json:"isbn"`
Description string `form:"description" json:"description"`
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
CoverAction string `form:"cover_action" json:"cover_action"`
Series string `form:"series" json:"series"`
SeriesNumber int32 `form:"series_number" json:"series_number"`
Tags []string `form:"tags" json:"tags"`
ASIN string `form:"asin" json:"asin"`
DatePublished string `form:"date_published" json:"date_published"`
Publisher string `form:"publisher" json:"publisher"`
Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"`
PageCount int32 `form:"page_count" json:"page_count"`
Genre string `form:"genre" json:"genre"`
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
MangaType string `form:"manga_type" json:"manga_type"`
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
SeriesCount int32 `form:"series_count" json:"series_count"`
Volume int32 `form:"volume" json:"volume"`
Imprint string `form:"imprint" json:"imprint"`
AgeRating string `form:"age_rating" json:"age_rating"`
WebURL string `form:"web_url" json:"web_url"`
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
CommunityRating float64 `form:"community_rating" json:"community_rating"`
StoryArc string `form:"story_arc" json:"story_arc"`
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
ScanInformation string `form:"scan_information" json:"scan_information"`
Summary string `form:"summary" json:"summary"`
}
// CreateMediaNoteRequest represents the request for creating a media note
@@ -124,6 +135,7 @@ type MediaHandler struct {
worker *services.Worker
libraryService *services.LibraryService
searchService *services.SearchService
progressSvc *wsync.ProgressService
}
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
@@ -138,6 +150,10 @@ func NewMediaHandler(db *database.Queries, libraryService *services.LibraryServi
return mh
}
func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
mh.progressSvc = svc
}
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
bookUUID, err := uuid.Parse(c.Param("uuid"))
if err != nil {
@@ -548,7 +564,22 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
PageCount: existingMedia.PageCount,
GoodreadsID: existingMedia.GoodreadsID,
OpenlibraryID: existingMedia.OpenlibraryID,
GoogleBooksID: existingMedia.GoogleBooksID,
CoverImagePath: existingMedia.CoverImagePath,
MangaType: existingMedia.MangaType,
ReadingDirection: existingMedia.ReadingDirection,
SeriesCount: existingMedia.SeriesCount,
Volume: existingMedia.Volume,
Imprint: existingMedia.Imprint,
AgeRating: existingMedia.AgeRating,
WebUrl: existingMedia.WebUrl,
MetadataNotes: existingMedia.MetadataNotes,
CommunityRating: existingMedia.CommunityRating,
StoryArc: existingMedia.StoryArc,
IsBlackAndWhite: existingMedia.IsBlackAndWhite,
AlternateInfo: existingMedia.AlternateInfo,
ScanInformation: existingMedia.ScanInformation,
Summary: existingMedia.Summary,
}
if update.Updates.Title != nil {
@@ -728,7 +759,7 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -836,7 +867,7 @@ func (mh *MediaHandler) GetMediaRating(c *echo.Context) error {
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -889,12 +920,12 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
progress, err := mh.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
progress, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{
"current_page": 0,
"total_pages": nil,
@@ -903,7 +934,27 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
resp := map[string]interface{}{
"id": progress.ID,
"media_item_id": progress.MediaItemID,
"user_id": progress.UserID,
"current_page": progress.CurrentPage,
"total_pages": progress.TotalPages,
"last_read_at": progress.LastReadAt,
"percentage": progress.Percentage,
"character_offset": progress.CharacterOffset,
"epubcfi": progress.Epubcfi,
"chapter": progress.Chapter,
"chapter_progress": progress.ChapterProgress,
"format_group": progress.FormatGroup,
"total_characters": progress.TotalCharacters,
"chapter_count": progress.ChapterCount,
"last_sync_device": progress.LastSyncDevice,
"last_sync_source": progress.LastSyncSource,
"last_sync_timestamp": progress.LastSyncTimestamp,
}
return c.JSON(http.StatusOK, resp)
}
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
@@ -921,24 +972,82 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
}
var req struct {
CurrentPage int32 `json:"current_page"`
TotalPages int32 `json:"total_pages"`
Epubcfi string `json:"epubcfi"`
Percentage float64 `json:"percentage"`
CurrentPage *int32 `json:"current_page"`
TotalPages *int32 `json:"total_pages"`
Epubcfi *string `json:"epubcfi"`
Percentage *float64 `json:"percentage"`
Chapter *int `json:"chapter"`
ChapterProgress *float64 `json:"chapter_progress"`
CharacterOffset *int64 `json:"character_offset"`
ReadingMode *string `json:"reading_mode"`
ZoomLevel *float64 `json:"zoom_level"`
ScrollX *float64 `json:"scroll_position_x"`
ScrollY *float64 `json:"scroll_position_y"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
if mh.progressSvc != nil {
saveReq := wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Source: "web",
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
Percentage: req.Percentage,
Epubcfi: req.Epubcfi,
CharacterOffset: req.CharacterOffset,
Chapter: req.Chapter,
ChapterProgress: req.ChapterProgress,
CurrentPage: nil,
TotalPages: nil,
ZoomLevel: req.ZoomLevel,
ScrollX: req.ScrollX,
ScrollY: req.ScrollY,
ReadingMode: req.ReadingMode,
DeviceType: "web",
DeviceName: "Web",
Broadcast: true,
}
if req.CurrentPage != nil {
cp := int(*req.CurrentPage)
saveReq.CurrentPage = &cp
}
if req.TotalPages != nil {
tp := int(*req.TotalPages)
saveReq.TotalPages = &tp
}
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, result)
}
percentage := 0.0
if req.Percentage != nil {
percentage = *req.Percentage
}
epubcfi := ""
if req.Epubcfi != nil {
epubcfi = *req.Epubcfi
}
currentPage := int32(0)
if req.CurrentPage != nil {
currentPage = *req.CurrentPage
}
totalPages := int32(0)
if req.TotalPages != nil {
totalPages = *req.TotalPages
}
progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Percentage: pgtype.Float8{Float64: req.Percentage, Valid: true},
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: pgtype.Int8{Valid: false},
Epubcfi: pgtype.Text{String: req.Epubcfi, Valid: req.Epubcfi != ""},
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
Chapter: pgtype.Int4{Valid: false},
ChapterProgress: pgtype.Float8{Valid: false},
ViewportX: pgtype.Float8{Valid: false},
@@ -950,8 +1059,8 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
ReadingMode: pgtype.Text{Valid: false},
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
LastSyncSource: pgtype.Text{String: "web", Valid: true},
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
CurrentPage: pgtype.Int4{Int32: currentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: totalPages, Valid: totalPages > 0},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1026,7 +1135,7 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
_, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1109,25 +1218,52 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil && req.ISBN != "" {
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
}
// Use normalized ISBN if valid, otherwise empty string
isbnValue := normalizedISBN
if err != nil {
isbnValue = ""
}
if req.CoverAction == "" {
req.CoverAction = "keep"
}
existing, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
coverPath := existing.CoverImagePath.String
if req.CoverAction == "remove" {
coverPath = ""
} else if req.CoverAction == "upload" {
file, err := c.FormFile("cover_file")
if err == nil {
savedPath, err := mh.saveCoverImage(*c, mediaUUID, file)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
}
coverPath = savedPath
}
}
var alternateInfoBytes []byte
if req.AlternateInfo != "" {
alternateInfoBytes = []byte(req.AlternateInfo)
}
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
@@ -1137,11 +1273,37 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: req.Contributors,
ContributorsSearch: contributorsSearch,
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
Edition: pgtype.Text{String: req.Edition, Valid: req.Edition != ""},
PageCount: pgtype.Int4{Int32: req.PageCount, Valid: req.PageCount > 0},
Genre: pgtype.Text{String: req.Genre, Valid: req.Genre != ""},
CopyrightYear: pgtype.Int4{Int32: req.CopyrightYear, Valid: req.CopyrightYear > 0},
GoodreadsID: pgtype.Text{String: req.GoodreadsID, Valid: req.GoodreadsID != ""},
OpenlibraryID: pgtype.Text{String: req.OpenlibraryID, Valid: req.OpenlibraryID != ""},
GoogleBooksID: pgtype.Text{String: req.GoogleBooksID, Valid: req.GoogleBooksID != ""},
MangaType: pgtype.Text{String: req.MangaType, Valid: req.MangaType != ""},
ReadingDirection: pgtype.Text{String: req.ReadingDirection, Valid: req.ReadingDirection != ""},
SeriesCount: pgtype.Int4{Int32: req.SeriesCount, Valid: req.SeriesCount > 0},
Volume: pgtype.Int4{Int32: req.Volume, Valid: req.Volume > 0},
Imprint: pgtype.Text{String: req.Imprint, Valid: req.Imprint != ""},
AgeRating: pgtype.Text{String: req.AgeRating, Valid: req.AgeRating != ""},
WebUrl: pgtype.Text{String: req.WebURL, Valid: req.WebURL != ""},
MetadataNotes: pgtype.Text{String: req.MetadataNotes, Valid: req.MetadataNotes != ""},
CommunityRating: pgtype.Float8{Float64: req.CommunityRating, Valid: req.CommunityRating > 0},
StoryArc: pgtype.Text{String: req.StoryArc, Valid: req.StoryArc != ""},
IsBlackAndWhite: pgtype.Bool{Bool: req.IsBlackAndWhite, Valid: req.IsBlackAndWhite},
AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if c.Request().Header.Get("HX-Request") == "true" {
c.Response().Header().Set("HX-Redirect", "/media/"+mediaID)
}
return c.JSON(http.StatusOK, item)
}
@@ -1237,7 +1399,7 @@ func (mh *MediaHandler) GetMediaNote(c *echo.Context) error {
note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1377,7 +1539,7 @@ func (mh *MediaHandler) GetMediaHighlight(c *echo.Context) error {
highlight, err := mh.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1684,3 +1846,53 @@ func jsonBytesToMap(b []byte) map[string]interface{} {
}
return result
}
func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file *multipart.FileHeader) (string, error) {
src, err := file.Open()
if err != nil {
return "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
imageData, err := io.ReadAll(src)
if err != nil {
return "", fmt.Errorf("failed to read uploaded file: %w", err)
}
if len(imageData) < 512 {
return "", fmt.Errorf("file too small to be a valid image")
}
contentType := http.DetectContentType(imageData)
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return "", fmt.Errorf("invalid image type: %s", contentType)
}
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return "", fmt.Errorf("media item not found: %w", err)
}
relativeFilePath := mediaItem.FilePath
if relativeFilePath == "" {
return "", fmt.Errorf("media item has no file path")
}
coverRelPath := relativeFilePath + ".cover.jpg"
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
if err != nil {
return "", fmt.Errorf("failed to resolve cover path: %w", err)
}
coverDir := filepath.Dir(coverFullPath)
if err := os.MkdirAll(coverDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cover directory: %w", err)
}
if err := os.WriteFile(coverFullPath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover file: %w", err)
}
return coverRelPath, nil
}
+5 -5
View File
@@ -194,7 +194,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
if err == nil {
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
for _, col := range collections {
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
if col.UserID.Valid && col.UserID.Bytes == userUUID {
entry.AddCategory(collectionScheme, col.Name)
}
}
@@ -312,7 +312,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
if err == nil {
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
for _, col := range collections {
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
if col.UserID.Valid && col.UserID.Bytes == userUUID {
entry.AddCategory(collectionScheme, col.Name)
}
}
@@ -367,7 +367,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
// Check if book is in visible library
visible := false
for _, lib := range libraries {
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
visible = true
break
}
@@ -514,7 +514,7 @@ func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
// Check if book is in visible library
visible := false
for _, lib := range libraries {
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
visible = true
break
}
@@ -655,7 +655,7 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
// Check if book is in visible library
visible := false
for _, lib := range libraries {
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
visible = true
break
}
+2 -2
View File
@@ -39,7 +39,7 @@ type ProcessingIssueStats struct {
// ListProcessingIssues returns all processing issues for a library
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
libraryID, err := uuid.Parse(c.Param("libraryId"))
libraryID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
}
@@ -73,7 +73,7 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
// GetProcessingIssueStats returns statistics about processing issues
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
libraryID, err := uuid.Parse(c.Param("libraryId"))
libraryID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
}
+23 -15
View File
@@ -5,6 +5,7 @@ import (
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"context"
"errors"
"net/http"
"strconv"
"time"
@@ -43,7 +44,7 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"progress": nil,
@@ -75,7 +76,7 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64
}
if progress.CharacterOffset.Valid {
response["location_references"].(map[string]interface{})["character"] = int64(progress.CharacterOffset.Int64)
response["location_references"].(map[string]interface{})["character"] = progress.CharacterOffset.Int64
}
deviceSync := map[string]interface{}{}
@@ -138,7 +139,7 @@ func (h *Handler) UpdateUniversalProgress(c *echo.Context) error {
}
currentPage := 0
totalPages := 200
totalPages := 0
if req.Location.Page != nil {
currentPage = *req.Location.Page
}
@@ -260,6 +261,8 @@ type ProgressWithMedia struct {
ProgressPercentage float64 `json:"-"`
EpubCFI string `json:"-"`
LastUpdated string `json:"-"`
FormatGroup string `json:"format_group"`
EstimatedPages int `json:"estimated_pages"`
}
// GetAllProgress retrieves all progress for a user with sync source info
@@ -303,7 +306,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = progress.LastReadAt.Time.Format("2006-01-02 15:04")
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
}
progressList = append(progressList, ProgressWithMedia{
@@ -317,10 +320,12 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
LastReadAt: progress.LastReadAt.Time,
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
ProgressPercentage: progress.Percentage.Float64,
ProgressPercentage: progress.Percentage.Float64 * 100,
EpubCFI: epubcfi,
LastUpdated: lastUpdated,
DeviceIcon: getDeviceIcon(deviceName),
FormatGroup: mediaItem.FormatGroup,
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
})
}
@@ -370,16 +375,19 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
}
progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
Author: author,
CoverImagePath: coverPath,
Percentage: progress.Percentage.Float64,
CurrentPage: progress.CurrentPage.Int32,
TotalPages: progress.TotalPages.Int32,
LastReadAt: progress.LastReadAt.Time,
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
Author: author,
CoverImagePath: coverPath,
Percentage: progress.Percentage.Float64,
CurrentPage: progress.CurrentPage.Int32,
TotalPages: progress.TotalPages.Int32,
LastReadAt: progress.LastReadAt.Time,
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
ProgressPercentage: progress.Percentage.Float64 * 100,
FormatGroup: mediaItem.FormatGroup,
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
})
}
+122
View File
@@ -3,6 +3,7 @@ package handlers
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
@@ -28,3 +29,124 @@ func TestGetDeviceIcon_Unknown(t *testing.T) {
result = getDeviceIcon("")
assert.Equal(t, "📚", result)
}
func TestTextPtrToPgText(t *testing.T) {
t.Run("nil returns invalid", func(t *testing.T) {
result := textPtrToPgText(nil)
assert.False(t, result.Valid)
})
t.Run("non-nil returns valid", func(t *testing.T) {
s := "epubcfi(/6/4/2:10)"
result := textPtrToPgText(&s)
assert.True(t, result.Valid)
assert.Equal(t, s, result.String)
})
t.Run("empty string returns valid", func(t *testing.T) {
s := ""
result := textPtrToPgText(&s)
assert.True(t, result.Valid)
assert.Equal(t, "", result.String)
})
}
func TestIntPtrToPgInt4(t *testing.T) {
t.Run("nil returns invalid", func(t *testing.T) {
result := intPtrToPgInt4(nil)
assert.False(t, result.Valid)
})
t.Run("non-nil returns valid", func(t *testing.T) {
v := 5
result := intPtrToPgInt4(&v)
assert.True(t, result.Valid)
assert.Equal(t, int32(5), result.Int32)
})
}
func TestInt64PtrToPgInt8(t *testing.T) {
t.Run("nil returns invalid", func(t *testing.T) {
result := int64PtrToPgInt8(nil)
assert.False(t, result.Valid)
})
t.Run("non-nil returns valid", func(t *testing.T) {
v := int64(10000)
result := int64PtrToPgInt8(&v)
assert.True(t, result.Valid)
assert.Equal(t, int64(10000), result.Int64)
})
}
func TestFloat64PtrHelpers(t *testing.T) {
t.Run("pgtype float64 valid", func(t *testing.T) {
v := pgtype.Float8{Float64: 0.5, Valid: true}
result := float64PtrVal(v)
assert.NotNil(t, result)
assert.InDelta(t, 0.5, *result, 0.001)
})
t.Run("pgtype float64 invalid", func(t *testing.T) {
v := pgtype.Float8{Valid: false}
result := float64PtrVal(v)
assert.Nil(t, result)
})
t.Run("pgtype text valid", func(t *testing.T) {
v := pgtype.Text{String: "hello", Valid: true}
result := textPtrVal(v)
assert.NotNil(t, result)
assert.Equal(t, "hello", *result)
})
t.Run("pgtype text invalid", func(t *testing.T) {
v := pgtype.Text{Valid: false}
result := textPtrVal(v)
assert.Nil(t, result)
})
t.Run("pgtype int4 valid", func(t *testing.T) {
v := pgtype.Int4{Int32: 42, Valid: true}
result := int32PtrVal(v)
assert.NotNil(t, result)
assert.Equal(t, 42, *result)
})
t.Run("pgtype int4 invalid", func(t *testing.T) {
v := pgtype.Int4{Valid: false}
result := int32PtrVal(v)
assert.Nil(t, result)
})
t.Run("pgtype int8 valid", func(t *testing.T) {
v := pgtype.Int8{Int64: 10000, Valid: true}
result := int64PtrVal(v)
assert.NotNil(t, result)
assert.Equal(t, int64(10000), *result)
})
t.Run("pgtype int8 invalid", func(t *testing.T) {
v := pgtype.Int8{Valid: false}
result := int64PtrVal(v)
assert.Nil(t, result)
})
}
func float64PtrVal(v pgtype.Float8) *float64 {
if v.Valid {
return &v.Float64
}
return nil
}
func textPtrVal(v pgtype.Text) *string {
if v.Valid {
return &v.String
}
return nil
}
func int32PtrVal(v pgtype.Int4) *int {
if v.Valid {
val := int(v.Int32)
return &val
}
return nil
}
func int64PtrVal(v pgtype.Int8) *int64 {
if v.Valid {
return &v.Int64
}
return nil
}
+4 -2
View File
@@ -310,7 +310,8 @@ func uuidPtrToString(u pgtype.UUID) *string {
if !u.Valid {
return nil
}
return new(uuid.UUID(u.Bytes).String())
s := uuid.UUID(u.Bytes).String()
return &s
}
func textPtrToString(t pgtype.Text) *string {
@@ -324,5 +325,6 @@ func timestamptzPtrToString(t pgtype.Timestamptz) *string {
if !t.Valid {
return nil
}
return new(t.Time.Format("2006-01-02T15:04:05Z07:00"))
timeFormat := t.Time.Format("2006-01-02T15:04:05Z07:00")
return &timeFormat
}
+9 -8
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"context"
"errors"
"fmt"
"net/http"
"os"
@@ -63,7 +64,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
// Get media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch media item"})
@@ -108,7 +109,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: userData.ID,
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
progress = database.ReadingProgress{}
}
@@ -316,7 +317,7 @@ func (h *ReaderHandler) GetReadingSpeed(c *echo.Context) error {
})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
// Return zero values if no reading has occurred
return c.JSON(http.StatusOK, map[string]interface{}{
"words_per_minute": 0,
@@ -361,7 +362,7 @@ func (h *ReaderHandler) UpdateReadingSpeed(c *echo.Context) error {
// Update reading speed using service
err = h.readerService.CalculateReadingSpeed(
c.Request().Context(),
uuid.UUID(user.ID.Bytes),
user.ID.Bytes,
parsedUUID,
req.PagesRead,
req.TimeSpentMinutes,
@@ -476,7 +477,7 @@ func (h *ReaderHandler) GetSettings(c *echo.Context) error {
user := c.Get("user").(database.Users)
// Use reader service to get settings
settings, err := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID.Bytes)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"})
}
@@ -509,13 +510,13 @@ func (h *ReaderHandler) UpdateSettings(c *echo.Context) error {
}
// Use reader service to update settings
err := h.readerService.UpdateSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes), settings)
err := h.readerService.UpdateSettings(c.Request().Context(), user.ID.Bytes, settings)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
}
// Return updated settings
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID.Bytes)
return c.JSON(http.StatusOK, updatedSettings)
}
@@ -597,7 +598,7 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
// Fetch media item
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
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"})
+6 -5
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"net/http"
"time"
@@ -26,7 +27,7 @@ func parseTokenUUID(tokenStr string) (pgtype.UUID, error) {
if err != nil {
return pgtype.UUID{}, err
}
return pgtype.UUID{Bytes: [16]byte(tokenUUID), Valid: true}, nil
return pgtype.UUID{Bytes: tokenUUID, Valid: true}, nil
}
type RefreshTokenResponse struct {
@@ -52,7 +53,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
tokenInfo, err := h.db.GetRefreshToken(c.Request().Context(), tokenUUID)
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to validate refresh token"})
@@ -88,7 +89,7 @@ func (h *AuthHandler) Logout(c *echo.Context) error {
}
err = h.db.RevokeRefreshToken(c.Request().Context(), tokenUUID)
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke refresh token"})
}
@@ -102,8 +103,8 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
expiresAt := time.Now().Add(refreshTokenExpiration)
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
Token: pgtype.UUID{Bytes: [16]byte(tokenUUID), Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
})
if err != nil {
+3 -3
View File
@@ -62,7 +62,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
}
// Fetch library folders from database
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryUUID), Valid: true})
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
}
@@ -261,7 +261,7 @@ func (h *Handler) StartWatchMode(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}); err != nil {
if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: libraryID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -289,7 +289,7 @@ func (h *Handler) StopWatchMode(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}); err != nil {
if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: libraryID, Valid: true}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
+117
View File
@@ -0,0 +1,117 @@
package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type SeriesHandler struct {
seriesService *services.SeriesService
}
func NewSeriesHandler(db *database.Queries) *SeriesHandler {
return &SeriesHandler{
seriesService: services.NewSeriesService(db),
}
}
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
limit := 20
if l := c.QueryParam("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 {
limit = v
if limit > 100 {
limit = 100
}
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
offset = v
}
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, limit, offset)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
}
type SeriesResponse struct {
Name string `json:"name"`
BookCount int64 `json:"book_count"`
TotalInSeries int `json:"total_in_series"`
CoverPaths []string `json:"cover_paths"`
LastEntryAt string `json:"last_entry_at"`
}
response := make([]SeriesResponse, 0, len(seriesList))
for _, s := range seriesList {
response = append(response, SeriesResponse{
Name: s.Name,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: s.CoverPaths,
LastEntryAt: s.LastEntryAt,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"series": response,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
seriesName := c.QueryParam("name")
if seriesName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
}
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), seriesName)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
}
bookCards := make([]BookInfo, 0, len(books))
for _, item := range books {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards = append(bookCards, BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"name": seriesName,
"books": bookCards,
"total": len(bookCards),
})
}
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
svc := services.NewSeriesService(db)
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
}
+45
View File
@@ -0,0 +1,45 @@
package handlers
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func TestNewSeriesHandler_NilDB(t *testing.T) {
handler := NewSeriesHandler(nil)
assert.NotNil(t, handler, "Handler should not be nil even with nil DB")
assert.NotNil(t, handler.seriesService, "Internal service should be initialized")
}
func TestSeriesHandler_TextToStringConversion(t *testing.T) {
tests := []struct {
name string
input pgtype.Text
expected string
}{
{
name: "valid author text",
input: pgtype.Text{String: "Brandon Sanderson", Valid: true},
expected: "Brandon Sanderson",
},
{
name: "empty valid text",
input: pgtype.Text{String: "", Valid: true},
expected: "",
},
{
name: "null text returns empty",
input: pgtype.Text{String: "ignored", Valid: false},
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := textToString(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
+98 -5
View File
@@ -171,7 +171,7 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
}
// Build sidecar config
config := SidecarConfig{
sidecarConfig := SidecarConfig{
Version: "1.0",
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
@@ -188,7 +188,7 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
LastUpdated: time.Now().Format(time.RFC3339),
}
return c.JSON(http.StatusOK, config)
return c.JSON(http.StatusOK, sidecarConfig)
}
// DownloadSidecarConfig generates a .bookhoard.json file for device setup
@@ -352,8 +352,8 @@ func (h *SidecarHandler) GetSystemConfiguration(c *echo.Context) error {
// Build config map
result := make(map[string]string)
for _, config := range configs {
result[config.Key] = config.Value
for _, systemConfig := range configs {
result[systemConfig.Key] = systemConfig.Value
}
return c.JSON(http.StatusOK, result)
@@ -388,6 +388,23 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
// Update each config value
for key, value := range req {
if key == "default_timezone" {
if _, err := time.LoadLocation(value); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid timezone",
})
}
err := h.db.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: value,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to update default timezone",
})
}
continue
}
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
Key: key,
Value: value,
@@ -408,6 +425,12 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to fetch updated configuration</div>`)
}
defaultTimezone := "UTC"
tz, err := h.db.GetSystemTimezone(ctx)
if err == nil && tz != "" {
defaultTimezone = tz
}
// Render success message with updated form
return c.HTML(http.StatusOK, fmt.Sprintf(`
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
@@ -437,6 +460,44 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
</button>
</div>
</div>
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
<select name="default_timezone" id="default_timezone" class="w-full px-4 py-2 rounded-lg border" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="UTC"%s>UTC (UTC+0)</option>
<option value="Pacific/Honolulu"%s>Hawaii (UTC-10)</option>
<option value="America/Anchorage"%s>Alaska (UTC-9/-8)</option>
<option value="America/Los_Angeles"%s>Pacific (UTC-8/-7)</option>
<option value="America/Denver"%s>Mountain (UTC-7/-6)</option>
<option value="America/Phoenix"%s>Mountain - no DST (UTC-7)</option>
<option value="America/Chicago"%s>Central (UTC-6/-5)</option>
<option value="America/New_York"%s>Eastern (UTC-5/-4)</option>
<option value="America/Sao_Paulo"%s>Brasilia (UTC-3/-2)</option>
<option value="Europe/London"%s>British (UTC+0/+1)</option>
<option value="Europe/Paris"%s>Central European (UTC+1/+2)</option>
<option value="Europe/Helsinki"%s>Eastern European (UTC+2/+3)</option>
<option value="Europe/Moscow"%s>Moscow (UTC+3)</option>
<option value="Asia/Tehran"%s>Iran (UTC+3:30)</option>
<option value="Asia/Dubai"%s>Gulf (UTC+4)</option>
<option value="Asia/Karachi"%s>Pakistan (UTC+5)</option>
<option value="Asia/Kolkata"%s>India (UTC+5:30)</option>
<option value="Asia/Dhaka"%s>Bangladesh (UTC+6)</option>
<option value="Asia/Bangkok"%s>Indochina (UTC+7)</option>
<option value="Asia/Shanghai"%s>China (UTC+8)</option>
<option value="Asia/Tokyo"%s>Japan/Korea (UTC+9)</option>
<option value="Australia/Darwin"%s>Australian Central (UTC+9:30)</option>
<option value="Australia/Sydney"%s>Australian Eastern (UTC+10/+11)</option>
<option value="Pacific/Auckland"%s>New Zealand (UTC+12/+13)</option>
</select>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
Save Settings
</button>
</div>
</div>
</form>
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
@@ -447,7 +508,32 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
<p><strong>Device Sync:</strong> %s/api/sync</p>
</div>
</div>
`, baseURL.Value, baseURL.Value, baseURL.Value, baseURL.Value))
`, baseURL.Value,
selectedAttr(defaultTimezone, "UTC"),
selectedAttr(defaultTimezone, "Pacific/Honolulu"),
selectedAttr(defaultTimezone, "America/Anchorage"),
selectedAttr(defaultTimezone, "America/Los_Angeles"),
selectedAttr(defaultTimezone, "America/Denver"),
selectedAttr(defaultTimezone, "America/Phoenix"),
selectedAttr(defaultTimezone, "America/Chicago"),
selectedAttr(defaultTimezone, "America/New_York"),
selectedAttr(defaultTimezone, "America/Sao_Paulo"),
selectedAttr(defaultTimezone, "Europe/London"),
selectedAttr(defaultTimezone, "Europe/Paris"),
selectedAttr(defaultTimezone, "Europe/Helsinki"),
selectedAttr(defaultTimezone, "Europe/Moscow"),
selectedAttr(defaultTimezone, "Asia/Tehran"),
selectedAttr(defaultTimezone, "Asia/Dubai"),
selectedAttr(defaultTimezone, "Asia/Karachi"),
selectedAttr(defaultTimezone, "Asia/Kolkata"),
selectedAttr(defaultTimezone, "Asia/Dhaka"),
selectedAttr(defaultTimezone, "Asia/Bangkok"),
selectedAttr(defaultTimezone, "Asia/Shanghai"),
selectedAttr(defaultTimezone, "Asia/Tokyo"),
selectedAttr(defaultTimezone, "Australia/Darwin"),
selectedAttr(defaultTimezone, "Australia/Sydney"),
selectedAttr(defaultTimezone, "Pacific/Auckland"),
baseURL.Value, baseURL.Value, baseURL.Value))
}
return c.JSON(http.StatusOK, map[string]string{
@@ -477,3 +563,10 @@ func sanitizeAll(s string, old string, new string) string {
}
return result
}
func selectedAttr(current, value string) string {
if current == value {
return " selected"
}
return ""
}
+30 -6
View File
@@ -2,8 +2,10 @@ package handlers
import (
"bookhoard/internal/database"
"errors"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v5"
@@ -30,6 +32,28 @@ type ScanSettingsResponse struct {
Message string `json:"message,omitempty"`
}
type UpdateTimezoneSettingsRequest struct {
DefaultTimezone string `json:"default_timezone" validate:"required"`
}
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
var req UpdateTimezoneSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
}
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: req.DefaultTimezone,
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
var req UpdateScanSettingsRequest
if err := c.Bind(&req); err != nil {
@@ -47,7 +71,7 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
SettingValue: scanFrequencyValue,
})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -58,7 +82,7 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
SettingValue: autoScanValue,
})
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -74,9 +98,9 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: 60,
ScanPollIntervalSeconds: 1800,
AutoScanEnabled: true,
})
}
@@ -85,9 +109,9 @@ func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
autoScanSetting, err := h.db.GetSystemSetting(c.Request().Context(), "auto_scan_enabled")
if err != nil {
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: 60,
ScanPollIntervalSeconds: 1800,
AutoScanEnabled: true,
})
}
+2 -1
View File
@@ -1,6 +1,7 @@
package middleware
import (
"errors"
"fmt"
"net/http"
@@ -83,7 +84,7 @@ func WrapHandler(fn func(*echo.Context) error) echo.HandlerFunc {
return func(c *echo.Context) error {
err := fn(c)
if err != nil {
if httpErr, ok := err.(*HTTPError); ok {
if httpErr, ok := errors.AsType[*HTTPError](err); ok {
return RespondWithHTTPError(c, httpErr)
}
return RespondWithError(c, http.StatusInternalServerError, "Internal server error", err)
+243 -177
View File
@@ -111,13 +111,169 @@ func registerFrontendRoutes(cfg *Config) {
// Protected frontend routes (no /api prefix)
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
// Helper to extract text from pgtype.Text
getText := func(t pgtype.Text) string {
if t.Valid {
return t.String
// Series browse page
frontendProtected.GET("/series", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
return ""
}
var errorMsg string
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libData := libRes.Libraries
if libRes.IsAll {
errorMsg = ""
}
perSeriesPage := 24
page := 1
if p := c.QueryParam("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
page = v
}
}
offset := (page - 1) * perSeriesPage
var seriesCards []templates.SeriesCardData
totalPages := 1
if errorMsg == "" {
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
if err != nil {
log.Printf("GetSeriesCardsData failed: %v", err)
errorMsg = "Error loading series"
} else {
totalPages = (total + perSeriesPage - 1) / perSeriesPage
if totalPages < 1 {
totalPages = 1
}
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
for _, s := range seriesList {
covers := s.CoverPaths
if covers == nil {
covers = []string{}
}
seriesCards = append(seriesCards, templates.SeriesCardData{
Name: s.Name,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: covers,
})
}
}
}
if seriesCards == nil {
seriesCards = []templates.SeriesCardData{}
}
var buf bytes.Buffer
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Series detail page (books in a specific series)
frontendProtected.GET("/series/detail", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
var errorMsg string
seriesName := c.QueryParam("name")
if seriesName == "" {
return renderErrorPage(c, "Series name required", "bad_request")
}
var bookInfoList []handlers.BookInfo
svc := services.NewSeriesService(cfg.Queries)
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
if err != nil {
log.Printf("GetSeriesBooks failed: %v", err)
errorMsg = "Error loading series books"
} else {
bookInfoList = make([]handlers.BookInfo, 0, len(books))
for _, item := range books {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookInfoList = append(bookInfoList, handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
})
}
}
if bookInfoList == nil {
bookInfoList = []handlers.BookInfo{}
}
var buf bytes.Buffer
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.GET("/tags/detail", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
var errorMsg string
tagName := c.QueryParam("name")
if tagName == "" {
return renderErrorPage(c, "Tag name required", "bad_request")
}
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var bookInfoList []handlers.BookInfo
if libraryID != "" && errorMsg == "" {
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
LibraryID: libRes.LibUUID,
Column2: tagName,
})
if err != nil {
log.Printf("GetBooksByTag failed: %v", err)
errorMsg = "Error loading tag books"
} else {
bookInfoList = make([]handlers.BookInfo, 0, len(books))
for _, item := range books {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookInfoList = append(bookInfoList, handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
})
}
}
}
if bookInfoList == nil {
bookInfoList = []handlers.BookInfo{}
}
var buf bytes.Buffer
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.GET("/bookshelf", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
@@ -127,52 +283,20 @@ func registerFrontendRoutes(cfg *Config) {
var errorMsg string
// Get library_id from query param or user's first library
libraryID := c.QueryParam("library_id")
if libraryID == "" {
userUUID, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
} else {
errorMsg = "No libraries available"
}
}
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libData := libRes.Libraries
// Get libraries for dropdown
// Fetch saved filters for SSR
userUUID, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("GetUserVisibleLibraries failed: %v", err)
libraries = []database.GetUserVisibleLibrariesRow{}
if errorMsg == "" {
errorMsg = "Error loading libraries"
}
}
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
}
}
// Fetch saved filters for SSR (using existing query)
var savedFilters []database.SavedFilters
if libraryID != "" && errorMsg == "" {
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
ResourceType: "media-items",
})
if err != nil {
log.Printf("GetSavedFilters failed: %v", err)
savedFilters = []database.SavedFilters{}
}
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
ResourceType: "media-items",
})
if err != nil {
log.Printf("GetSavedFilters failed: %v", err)
savedFilters = []database.SavedFilters{}
}
// Fetch first page of books for SSR
@@ -181,64 +305,51 @@ func registerFrontendRoutes(cfg *Config) {
limit := 50
offset := 0
if libraryID != "" && errorMsg == "" {
libUUID, err := uuid.Parse(libraryID)
if err == nil {
// Check URL params for pagination
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
if errorMsg == "" {
// Check URL params for pagination
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
params := services.SearchParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: libRes.LibUUID,
SearchQuery: "",
AuthorFilter: "",
SeriesFilter: "",
GenreFilter: "",
TagsFilter: "",
LanguageFilter: "",
YearMin: 0,
YearMax: 0,
HasCover: pgtype.Bool{Valid: false},
Sort: "created_at DESC",
Limit: limit,
Offset: offset,
}
var results []database.SearchMediaItemsUnifiedRow
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
if err != nil {
log.Printf("ExecuteSearch failed: %v", err)
} else {
bookInfoList = make([]handlers.BookInfo, len(results))
for i, book := range results {
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
bookInfoList[i] = handlers.BookInfo{
MediaItemID: bookUUID.String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
}
}
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
// Convert user.ID (string) to pgtype.UUID for service layer
userUUID, err := uuid.Parse(user.ID)
if err != nil {
log.Printf("Failed to parse user ID: %v", err)
return renderErrorPage(c, "Error loading user", "user_id_error")
}
// Build search params (same as search.go:76-91)
params := services.SearchParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
SearchQuery: "", // Empty for initial SSR load
AuthorFilter: "",
SeriesFilter: "",
GenreFilter: "",
TagsFilter: "",
LanguageFilter: "",
YearMin: 0,
YearMax: 0,
HasCover: pgtype.Bool{Valid: false},
Sort: "created_at DESC",
Limit: limit,
Offset: offset,
}
// Execute search using the same handler as API (search.go:93)
var results []database.SearchMediaItemsUnifiedRow
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
if err != nil {
log.Printf("ExecuteSearch failed: %v", err)
// Continue without books - will show empty state
} else {
// Convert to BookInfo (same as search.go:99-109)
bookInfoList = make([]handlers.BookInfo, len(results))
for i, book := range results {
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
bookInfoList[i] = handlers.BookInfo{
MediaItemID: bookUUID.String(),
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
}
}
log.Printf("SSR: fetched %d books for library %s", len(bookInfoList), libraryID)
}
}
}
@@ -259,20 +370,13 @@ func registerFrontendRoutes(cfg *Config) {
var errorMsg string
libraryID := c.QueryParam("library_id")
if libraryID == "" {
userUUID, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
pgLibUUID := libRes.LibUUID
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, pgLibUUID)
if err != nil {
log.Printf("GetDashboardPreferences failed: %v", err)
prefs = database.UserDashboardPreferences{
@@ -290,7 +394,7 @@ func registerFrontendRoutes(cfg *Config) {
allSections, err := cfg.DashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
pgLibUUID,
limit,
prefs.CollectionOrder,
[]string{}, // No filtering - get all sections
@@ -304,32 +408,11 @@ func registerFrontendRoutes(cfg *Config) {
// Get only visible sections for the dashboard display
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
userUUID2, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
if err != nil {
log.Printf("GetUserVisibleLibraries failed: %v", err)
libraries = []database.GetUserVisibleLibrariesRow{}
if errorMsg == "" {
errorMsg = "Error loading libraries"
}
}
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
}
}
sectionData := handlers.BuildSections(visibleSections, libraryID)
allSectionsData := handlers.BuildSections(allSections, libraryID)
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -453,30 +536,18 @@ func registerFrontendRoutes(cfg *Config) {
userUUID, _ := uuid.Parse(user.ID)
var books []handlers.BookInfo
if collection.QueryType.Valid && collection.QueryType.String != "" {
// System collection - use query type
// System collection - need library_id for system collections
// Get library_id from query param or default to user's first library
libraryID := c.QueryParam("library_id")
if libraryID == "" {
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libUUID, _ := uuid.Parse(libraryID)
if collection.QueryType.Valid && collection.QueryType.String != "" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if err != nil {
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
return renderErrorPage(c, "Error loading books", "books_load_error")
}
// Find the matching section and convert items
for _, section := range sections {
if section.CollectionID.String() == collectionID {
// Convert []database.MediaItems to []handlers.BookInfo
bookCards := make([]handlers.BookInfo, len(section.Items))
for i, item := range section.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
@@ -492,27 +563,21 @@ func registerFrontendRoutes(cfg *Config) {
}
}
} else {
// User collection - check if library_id filter is present
libraryID := c.QueryParam("library_id")
if libraryID != "" {
// Filter by library - reuse dashboard query
libUUID, err := uuid.Parse(libraryID)
if err != nil {
if libraryID != "" && !libRes.IsAll {
libUUID, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
}
// Use GetCollectionItemsForDashboard for library-filtered results
collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: 1000,
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if err != nil {
if collErr != nil {
books = []handlers.BookInfo{}
} else {
// Convert to BookInfo format (non-excluded only)
var validItems []database.GetCollectionItemsForDashboardRow
for _, item := range collItems {
if !item.Excluded.Valid || !item.Excluded.Bool {
@@ -533,13 +598,11 @@ func registerFrontendRoutes(cfg *Config) {
books = bookCards
}
} else {
// No library filter - show all books in collection
collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if collErr != nil {
books = []handlers.BookInfo{}
}
// Convert to BookInfo format
bookCards := make([]handlers.BookInfo, len(collItems))
for i, item := range collItems {
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
@@ -562,11 +625,8 @@ func registerFrontendRoutes(cfg *Config) {
Icon: collection.Icon.String,
}
// Get library_id from query params for template
libraryID := c.QueryParam("library_id")
// Render the CollectionDetail template
var buf bytes.Buffer
err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Request().Context(), &buf)
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -932,7 +992,13 @@ func registerFrontendRoutes(cfg *Config) {
}
systemConfig := map[string]string{
"base_url": baseURL,
"base_url": baseURL,
"default_timezone": "UTC",
}
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
if err == nil && defaultTimezone != "" {
systemConfig["default_timezone"] = defaultTimezone
}
var buf bytes.Buffer
+83 -1
View File
@@ -3,7 +3,9 @@ package router
import (
"context"
"log"
"net/url"
"bookhoard/internal/database"
"bookhoard/templates"
"github.com/google/uuid"
@@ -35,6 +37,11 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
userTheme = userDB.Theme.String
}
userTimezone := "UTC"
if userDB.Timezone.Valid {
userTimezone = userDB.Timezone.String
}
// Extract JWT token for WebSocket authentication
token := ""
if cookie, err := c.Cookie("token"); err == nil {
@@ -48,6 +55,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
}, nil
}
@@ -78,5 +86,79 @@ func parseUUID(s string) (uuid.UUID, error) {
}
func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
return pgtype.UUID{Bytes: u, Valid: true}
}
const selectedLibraryCookie = "selectedLibrary"
const allLibrariesSentinel = "__all__"
type LibraryResolution struct {
LibraryID string
IsAll bool
LibUUID pgtype.UUID
Libraries []templates.LibraryData
FirstID string
}
func getText(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution {
res := LibraryResolution{}
userU, _ := uuid.Parse(userUUID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
if err != nil {
log.Printf("GetUserVisibleLibraries failed: %v", err)
libraries = []database.GetUserVisibleLibrariesRow{}
}
res.Libraries = make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
res.Libraries[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
}
}
if len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
res.FirstID = libUUID.String()
}
libraryID := c.QueryParam("library_id")
if libraryID == "" {
if cookie, err := c.Cookie(selectedLibraryCookie); err == nil {
val, _ := url.QueryUnescape(cookie.Value)
if val == allLibrariesSentinel {
res.IsAll = true
res.LibraryID = ""
return res
}
if _, parseErr := uuid.Parse(val); parseErr == nil {
libraryID = val
}
}
}
if libraryID == "" {
res.LibraryID = res.FirstID
if res.LibraryID != "" {
parsed, _ := uuid.Parse(res.LibraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
return res
}
res.LibraryID = libraryID
parsed, _ := uuid.Parse(libraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
return res
}
+1 -1
View File
@@ -22,7 +22,7 @@ func registerMediaRoutes(cfg *Config) {
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
// Legacy progress routes (all authenticated users)
// Progress routes (all authenticated users)
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)
protected.DELETE("/media-items/:id/progress", cfg.MediaHandler.DeleteMediaReadingProgress)
-4
View File
@@ -7,12 +7,8 @@ import (
func registerProgressRoutes(cfg *Config, scannerHandler *handlers.Handler) {
e := cfg.Echo
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
// Universal Progress routes
protected.GET("/progress/:id", scannerHandler.GetUniversalProgress)
protected.POST("/progress/:id", scannerHandler.UpdateUniversalProgress)
protected.GET("/progress/:id/history", scannerHandler.GetProgressHistory)
}
+22 -13
View File
@@ -4,9 +4,11 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"fmt"
"errors"
"net/http"
"github.com/google/uuid"
@@ -73,7 +75,7 @@ func registerReaderRoutes(cfg *Config) {
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: uuidToPGType(userUUID),
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
progress = database.ReadingProgress{}
}
// Get bookmarks
@@ -98,21 +100,26 @@ func registerReaderRoutes(cfg *Config) {
MangaType: textToString(mediaItem.MangaType),
ReadingDirection: textToString(mediaItem.ReadingDirection),
LibraryID: libUUID.String(),
FileURL: fmt.Sprintf("/uploads/library-%s/%s", libUUID.String(), mediaItem.FilePath),
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
}
// Progress conversion (inline)
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
templateProgress := templates.ReadingProgress{
ID: progressUUID.String(),
MediaItemID: progressMediaUUID.String(),
UserID: progressUserUUID.String(),
CurrentPage: int(progress.CurrentPage.Int32),
TotalPages: int(progress.TotalPages.Int32),
Percentage: progress.Percentage.Float64,
EpubCfi: textToString(progress.Epubcfi),
LastReadAt: progress.LastReadAt.Time,
ID: progressUUID.String(),
MediaItemID: progressMediaUUID.String(),
UserID: progressUserUUID.String(),
CurrentPage: int(progress.CurrentPage.Int32),
TotalPages: int(progress.TotalPages.Int32),
Percentage: progress.Percentage.Float64 * 100,
EpubCfi: textToString(progress.Epubcfi),
LastReadAt: progress.LastReadAt.Time,
Chapter: int(progress.Chapter.Int32),
ChapterProgress: progress.ChapterProgress.Float64 * 100,
FormatGroup: mediaItem.FormatGroup,
}
// Bookmarks conversion (inline, with loop)
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
@@ -123,12 +130,14 @@ func registerReaderRoutes(cfg *Config) {
var pageNumber *int
if b.PageNumber.Valid {
pageNumber = new(int(b.PageNumber.Int32))
val := int(b.PageNumber.Int32)
pageNumber = &val
}
var chapterNumber *int
if b.ChapterNumber.Valid {
chapterNumber = new(int(b.ChapterNumber.Int32))
val := int(b.ChapterNumber.Int32)
chapterNumber = &val
}
templateBookmarks[i] = templates.Bookmark{
+4 -1
View File
@@ -56,10 +56,12 @@ type Config struct {
FiltersHandler *handlers.FiltersHandler
DashboardHandler *handlers.DashboardHandler
DashboardService *services.DashboardService
SeriesHandler *handlers.SeriesHandler
OPDSHandler *handlers.OPDSHandler
SystemSettingsHandler *handlers.SystemSettingsHandler
ConnManager *sync.ConnectionManager
QueueProcessor *sync.SyncQueueProcessor
ProgressService *sync.ProgressService
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
@@ -90,7 +92,7 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
}
c.Set("user", database.Users{
ID: pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true},
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Email: claims["user_email"].(string),
Username: claims["user_username"].(string),
Role: claims["user_role"].(string),
@@ -210,6 +212,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
registerSystemRoutes(cfg)
registerSyncRoutes(cfg)
registerCollectionsRoutes(cfg)
registerSeriesRoutes(cfg)
registerDashboardRoutes(cfg)
registerMediaRoutes(cfg)
registerSearchRoutes(cfg)
+10
View File
@@ -0,0 +1,10 @@
package router
func registerSeriesRoutes(cfg *Config) {
jwtMiddleware := createJWTMiddleware(cfg)
protected := cfg.Echo.Group("/api", jwtMiddleware)
series := protected.Group("/series")
series.GET("", cfg.SeriesHandler.GetSeries)
series.GET("/books", cfg.SeriesHandler.GetSeriesBooks)
}
+1
View File
@@ -32,6 +32,7 @@ func registerSyncRoutes(cfg *Config) {
// Kobo devices use URL path: /api/sync/kobo/{token}/markup
// API clients can use Authorization header: Authorization: Bearer {token}
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
koboHandler.SetProgressService(cfg.ProgressService)
koboSync := e.Group("/api/sync/kobo/:token")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
+5 -1
View File
@@ -12,6 +12,7 @@ import (
"time"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -81,7 +82,10 @@ func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID
}
var fileSize pgtype.Int8
fileSize.Scan(int64(fileinfo.Size()))
err = fileSize.Scan(fileinfo.Size())
if err != nil {
return nil, err
}
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
MediaItemID: mediaItemID,
+34 -18
View File
@@ -135,7 +135,8 @@ type DashboardSection struct {
func (s *DashboardService) GetDashboardSections(
ctx context.Context,
userID, libraryID uuid.UUID,
userID uuid.UUID,
libraryID pgtype.UUID,
limit int,
collectionOrder []string,
hiddenCollections []string,
@@ -154,7 +155,7 @@ func (s *DashboardService) GetDashboardSections(
}
results = append(results, DashboardSection{
CollectionID: uuid.UUID(coll.ID.Bytes),
CollectionID: coll.ID.Bytes,
CollectionName: coll.Name,
Items: items,
QueryType: coll.QueryType.String,
@@ -182,7 +183,7 @@ func (s *DashboardService) GetDashboardSections(
}
results = append(results, DashboardSection{
CollectionID: uuid.UUID(coll.ID.Bytes),
CollectionID: coll.ID.Bytes,
CollectionName: coll.Name,
Items: items,
QueryType: coll.QueryType.String,
@@ -267,43 +268,57 @@ func (s *DashboardService) sortByPriority(sections []DashboardSection) []Dashboa
return sorted
}
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
switch coll.QueryType.String {
case "continue-reading":
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
case "recently-added":
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
case "recently-read":
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
case "not-started":
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
case "continue-series":
rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{
LibraryID: libraryID,
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
if err != nil {
return nil, err
}
items := make([]database.MediaItems, 0, len(rows))
for _, row := range rows {
items = append(items, continueSeriesRowToMediaItems(row))
}
return items, nil
default:
return []database.MediaItems{}, nil
}
}
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
if err != nil {
return nil, err
@@ -320,7 +335,7 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
if len(coll.AutoAssignRules) > 0 {
var rules []Rule
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID)
if err == nil {
for _, item := range allLibraryItems {
alreadyInCollection := false
@@ -360,10 +375,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
return finalItems, nil
}
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
LibraryID: libraryID,
})
}
@@ -407,6 +422,7 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
"Recently Added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"},
"Recently Read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"},
"Not Started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"},
"Continue Series": {"Next book in series you're reading", "📚", "#bb9af7", 5, "continue-series"},
}
meta, exists := defaultMetadata[collectionName]
+16 -2
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@@ -30,8 +31,8 @@ const (
var AllowedExtensions = map[string][]string{
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
}
var MimeTypes = map[string]string{
@@ -286,6 +287,19 @@ func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]
return dirs, cleanPath, parentPath, nil
}
// SyncAllowedExtensions syncs the Go AllowedExtensions map into the database.
// This ensures library_types.allowed_extensions stays in sync with the Go source of truth.
func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
for typeName, exts := range AllowedExtensions {
if err := s.db.SyncLibraryTypeExtensions(ctx, database.SyncLibraryTypeExtensionsParams{
Name: typeName,
AllowedExtensions: exts,
}); err != nil {
log.Printf("Warning: failed to sync allowed extensions for library type %s: %v", typeName, err)
}
}
}
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
// Get library folders for this library
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
+411 -217
View File
@@ -15,6 +15,7 @@ import (
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"image"
_ "image/jpeg"
@@ -74,6 +75,9 @@ type MediaMetadata struct {
WebURL string // URL to info page (Goodreads, ComicVine, etc.)
MetadataNotes string // Notes from metadata files (not user notes)
CommunityRating float64 // Pre-existing community rating (0-10)
PageCount int32 // Actual page count (images for comics, pages for PDF)
TotalCharacters int64 // Total text characters (for reflowable EPUBs)
ChapterCount int32 // Number of chapters detected
// Comic-specific fields
StoryArc string // Story arc name
@@ -114,7 +118,6 @@ type MediaScanner struct {
fileStabilityMu sync.RWMutex
scanMutex sync.Mutex
scanInProgress atomic.Bool
pollInterval time.Duration
watching atomic.Bool
settingsCache *SettingsCache
@@ -151,24 +154,22 @@ func NewMediaScanner(db *database.Queries) *MediaScanner {
}
return &MediaScanner{
db: db,
watcher: watcher,
settingsCache: NewSettingsCache(30 * time.Second),
dirtyDirs: make(map[string]time.Time),
fileStability: make(map[string]*atomic.Bool),
pollInterval: 60 * time.Second,
watching: atomic.Bool{},
scanInProgress: atomic.Bool{},
folders: []string{},
adminID: pgtype.UUID{},
db: db,
watcher: watcher,
settingsCache: NewSettingsCache(30 * time.Second),
dirtyDirs: make(map[string]time.Time),
fileStability: make(map[string]*atomic.Bool),
watching: atomic.Bool{},
scanInProgress: atomic.Bool{},
folders: []string{},
adminID: pgtype.UUID{},
defaultLibraryID: pgtype.UUID{Valid: false},
libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
}
}
func (s *MediaScanner) GetPollInterval() time.Duration {
// Check cache first
if cached, ok := s.settingsCache.Get("scan_poll_interval_seconds"); ok {
if seconds, err := strconv.Atoi(cached); err == nil {
return time.Duration(seconds) * time.Second
@@ -176,25 +177,22 @@ func (s *MediaScanner) GetPollInterval() time.Duration {
}
if s.db == nil {
return 60 * time.Second
return 5 * time.Minute
}
// Cache miss - query database
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
setting, err := s.db.GetSystemSetting(ctx, "scan_poll_interval_seconds")
if err != nil || setting == "" {
return 60 * time.Second
return 5 * time.Minute
}
// Store in cache
s.settingsCache.Set("scan_poll_interval_seconds", setting)
// Convert to duration
seconds, err := strconv.Atoi(setting)
if err != nil {
return 60 * time.Second
return 5 * time.Minute
}
return time.Duration(seconds) * time.Second
}
@@ -260,40 +258,122 @@ func (s *MediaScanner) SetFolders(folders []string) error {
s.watcher = watcher
// Build cache of allowed extensions per folder
// Uses Go AllowedExtensions map as source of truth (not DB)
s.libraryTypes = make(map[string][]string)
ctx := context.Background()
for _, folder := range folders {
// Get library for this folder
lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil {
fmt.Printf("Warning: failed to get library for folder %s: %v\n", folder, err)
continue
}
// Get library type with allowed extensions
libType, err := s.db.GetLibraryType(ctx, lib.LibraryTypeID)
if err != nil {
fmt.Printf("Warning: failed to get library type for %s: %v\n", folder, err)
continue
}
// Cache allowed extensions for this folder
s.libraryTypes[folder] = libType.AllowedExtensions
fmt.Printf("Scanner: Folder %s (type: %s) allows extensions: %v\n",
folder, libType.Name, libType.AllowedExtensions)
}
// Add all folders to watch
for _, folder := range folders {
if err := s.watcher.Add(folder); err != nil {
fmt.Printf("Warning: failed to watch folder %s: %v\n", folder, err)
if exts, ok := AllowedExtensions[libType.Name]; ok {
s.libraryTypes[folder] = exts
fmt.Printf("Scanner: Folder %s (type: %s) allows extensions: %v\n",
folder, libType.Name, exts)
} else {
s.libraryTypes[folder] = libType.AllowedExtensions
fmt.Printf("Scanner: Folder %s (type: %s) using DB extensions (no Go map entry): %v\n",
folder, libType.Name, libType.AllowedExtensions)
}
}
// Add all folders and their subdirectories to the watcher (like Audiobookshelf)
watchCount := 0
for _, folder := range folders {
if err := s.watcher.Add(folder); err != nil {
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
} else {
watchCount++
}
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() || path == folder {
return nil
}
if err := s.watcher.Add(path); err != nil {
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
} else {
watchCount++
}
return nil
})
}
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
return nil
}
func (s *MediaScanner) enqueueLibraryScan(rootFolder string) {
if s.db == nil {
return
}
libRow, err := s.db.GetLibraryByFolderPathPrefix(context.Background(), rootFolder)
if err != nil {
fmt.Printf("[MTIME-POLL] Warning: could not find library for %s: %v\n", rootFolder, err)
return
}
folders, err := s.db.GetLibraryFolders(context.Background(), libRow.LibraryID)
if err != nil {
fmt.Printf("[MTIME-POLL] Warning: could not get folders for library: %v\n", err)
return
}
folderPaths := make([]string, len(folders))
for i, f := range folders {
folderPaths[i] = f.FolderPath
}
adminIDStr := ""
if libRow.CreatedByAdminID.Valid {
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String()
}
if adminIDStr == "" {
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
if err != nil {
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
return
}
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
}
libraryIDStr := uuid.UUID(libRow.LibraryID.Bytes).String()
job := &Job{
ID: uuid.New().String(),
Type: JobTypeScan,
Status: JobStatusPending,
UserID: adminIDStr,
Context: context.Background(),
Params: map[string]any{
"library_id": libraryIDStr,
"folders": folderPaths,
"admin_id": adminIDStr,
"db": s.db,
"force": false,
},
}
if WorkerInstance != nil {
WorkerInstance.Enqueue(job)
fmt.Printf("[MTIME-POLL] Enqueued library scan for %s (library: %s)\n", rootFolder, libraryIDStr)
}
}
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
if len(s.folders) == 0 {
return fmt.Errorf("no folders set")
@@ -531,6 +611,26 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
return metadata
}
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,
".cbz": true, ".cbr": true, ".cb7": true, ".cbt": true,
}
func hasSiblingBookFile(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() && bookExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
return true
}
}
return false
}
func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, error) {
fmt.Printf("Processing media file: %s\n", path)
@@ -541,9 +641,10 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
return false, fmt.Errorf("failed to get file info: %v", err)
}
fmt.Printf("File info for %s: size=%d\n", path, info.Size())
if isImageFile(path) && hasSiblingBookFile(filepath.Dir(path)) {
return false, nil
}
// Get file modification time for created_at
fileModTime := info.ModTime()
// Find library for this file's folder
@@ -590,7 +691,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
fmt.Printf("Media item already exists with same size, skipping: %s\n", path)
return false, nil
}
} else if err != pgx.ErrNoRows {
} else if !errors.Is(err, pgx.ErrNoRows) {
fmt.Printf("Database error checking media item existence: %v\n", err)
return false, fmt.Errorf("failed to check if media item exists: %v", err)
}
@@ -687,6 +788,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
TagsSearch: tagsSearch,
AddedByAdminID: s.adminID,
CreatedAt: pgtype.Timestamptz{Time: fileModTime, Valid: true},
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
@@ -706,6 +808,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
})
if err != nil {
return false, fmt.Errorf("failed to create media item: %v", err)
@@ -725,6 +828,46 @@ 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":
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"
}
err = s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: createdItem.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},
})
if err != nil {
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
}
// Store format information in the database
for _, format := range metadata.FileFormats {
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
@@ -782,6 +925,16 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
if err == nil {
genreTags := extractGenreTagsFromEPUB(book)
processGenresAndTags(metadata, genreTags)
if allText := book.AllChaptersText(); len(allText) > 0 {
metadata.TotalCharacters = int64(len(allText))
}
metadata.ChapterCount = int32(book.ChapterCount())
}
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
if fixedErr == nil && isFixed {
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
metadata.PageCount = int32(pageCount)
}
}
}
@@ -898,6 +1051,10 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
fmt.Printf("Merged comic metadata from %s: title=%s, series=%s, issue=%d, manga=%s, direction=%s\n",
path, comicInfo.Title, comicInfo.Series, comicInfo.Number, comicInfo.Manga, metadata.ReadingDirection)
}
if pageCount, err := countArchiveImages(path); err == nil && pageCount > 0 {
metadata.PageCount = int32(pageCount)
}
}
return metadata, nil
@@ -953,7 +1110,7 @@ func determineReadingDirection(comicInfo *ComicInfo) string {
if strings.Contains(tags, "webtoon") || strings.Contains(tags, "manhwa") {
return "vertical" // Korean/Chinese webcomics
}
if strings.Contains(tags, "manga") && (lang == "ja" || lang == "jpn") {
if strings.Contains(tags, "manga") {
return "rtl" // Japanese manga
}
@@ -1076,38 +1233,62 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub":
metadata, err := s.extractEPUBMetadata(path)
case ".epub", ".kepub":
metadata := &MediaMetadata{}
result, err := s.extractEPUBMetadata(path)
if err == nil {
return result, nil
}
if result != nil {
metadata = result
}
// Enhanced format detection for EPUBs
isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path)
if detectErr == nil && isFixedLayout {
metadata.FileFormats = []*FormatInfo{{
FormatType: "fixed_layout",
FilePath: path,
MimeType: s.getMimeType(path),
}}
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
metadata.PageCount = int32(pageCount)
}
}
// Try to extract embedded cover
coverPath, err := s.extractEPUBCover(path)
if err != nil {
// Enhanced format detection for EPUBs
isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path)
if detectErr == nil && isFixedLayout {
// Override format group for manga EPUBs
metadata.FileFormats = []*FormatInfo{{
FormatType: "fixed_layout",
FilePath: path,
MimeType: s.getMimeType(path),
}}
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
} else if coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
// If no embedded cover, try sidecar
if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path)
if sidecarCover != "" {
metadata.CoverPath = s.getRelativePath(sidecarCover)
}
// Try to extract embedded cover
coverPath, err := s.extractEPUBCover(path)
if err != nil {
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
} else if coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
// If no embedded cover, try sidecar
if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path)
if sidecarCover != "" {
metadata.CoverPath = s.getRelativePath(sidecarCover)
}
}
return metadata, nil
}
return metadata, nil
case ".pdf":
return s.extractPDFMetadata(path)
case ".cbz", ".cbr", ".cb7", ".cbt":
metadata, err := s.mergeMetadata(path, nil)
if err != nil {
return &MediaMetadata{
Title: strings.TrimSuffix(filepath.Base(path), ext),
}, nil
}
if metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), ext)
}
// If no cover from archive, try sidecar
if metadata.CoverPath == "" {
sidecarCover := findSidecarCover(path)
if sidecarCover != "" {
metadata.CoverPath = s.getRelativePath(sidecarCover)
}
}
return metadata, nil
default:
// For other formats, return basic metadata
return &MediaMetadata{
@@ -1297,12 +1478,13 @@ func (s *MediaScanner) ValidateMediaItemForLibrary(
// Must be fixed-layout or comic archive
if mediaItem.FormatGroup != "fixed_layout" &&
mediaItem.FormatGroup != "comic_archive" {
return new(fmt.Sprintf(
str := fmt.Sprintf(
"EPUB file '%s' is reflowable (text-based), not fixed-layout (image-based). "+
"Manga library only accepts fixed-layout EPUBs, CBZ, CBR, or image files. "+
"Consider moving this file to an ebooks library.",
mediaItem.Title,
))
)
return &str
}
// Set manga-specific flags for fixed-layout EPUBs
@@ -1327,11 +1509,12 @@ func (s *MediaScanner) ValidateMediaItemForLibrary(
// Accept comic archives and fixed-layout
if mediaItem.FormatGroup != "comic_archive" &&
mediaItem.FormatGroup != "fixed_layout" {
return new(fmt.Sprintf(
str := fmt.Sprintf(
"File '%s' is not a comic archive format. "+
"Comics library only accepts CBZ, CBR, CB7, CBT, PDF, or fixed-layout EPUBs.",
mediaItem.Title,
))
)
return &str
}
}
@@ -1340,11 +1523,12 @@ func (s *MediaScanner) ValidateMediaItemForLibrary(
// Flag manga for potential reorganization (info level)
if mediaItem.FormatGroup == "fixed_layout" &&
(!mediaItem.MangaType.Valid || mediaItem.MangaType.String == "yes" || mediaItem.MangaType.String == "yes_and_right_to_left") {
return new(fmt.Sprintf(
str := fmt.Sprintf(
"File '%s' appears to be manga (fixed-layout with images). "+
"Consider moving to a manga or comics library for better organization.",
mediaItem.Title,
))
)
return &str
}
}
@@ -1531,7 +1715,7 @@ func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) {
opfStartAttr += len("full-path=")
quote := content[opfStart+opfStartAttr]
opfStartQuote := opfStart + opfStartAttr + 1
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{byte(quote)})
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
if opfEndQuote == -1 {
continue
}
@@ -1806,6 +1990,8 @@ func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
metadata.Publisher = pdfInfo.Producer
}
metadata.PageCount = int32(pdfInfo.PageCount)
// Try to extract cover image
coverPath, err := s.extractPDFCover(path)
if err != nil {
@@ -2250,7 +2436,81 @@ func (t *tarFileAdapter) Open() (io.ReadCloser, error) {
// isImageFile checks if a file is an image based on extension
func isImageFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif"
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".tiff", ".tif":
return true
}
return false
}
// countArchiveImages counts image files in a comic archive
func countArchiveImages(filePath string) (int, error) {
ext := strings.ToLower(filepath.Ext(filePath))
count := 0
switch ext {
case ".cbz", ".epub":
r, err := zip.OpenReader(filePath)
if err != nil {
return 0, err
}
defer r.Close()
for _, f := range r.File {
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
count++
}
}
case ".cbr":
r, err := rardecode.OpenReader(filePath, "")
if err != nil {
return 0, err
}
defer r.Close()
for {
header, err := r.Next()
if err == io.EOF {
break
}
if err != nil {
break
}
if !header.IsDir && isImageFile(header.Name) {
count++
}
}
case ".cb7":
sz, err := sevenzip.OpenReader(filePath)
if err != nil {
return 0, err
}
defer sz.Close()
for _, f := range sz.File {
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
count++
}
}
case ".cbt":
f, err := os.Open(filePath)
if err != nil {
return 0, err
}
defer f.Close()
tr := tar.NewReader(f)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
break
}
if !header.FileInfo().IsDir() && isImageFile(header.Name) {
count++
}
}
}
return count, nil
}
func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error {
@@ -2268,6 +2528,10 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
tagsSearch := utils.NormalizeTagsSearch(metadata.Tags)
// Call the database update - only update fields available in MediaMetadata
var alternateInfoBytes []byte
if metadata.AlternateInfo != "" {
alternateInfoBytes = []byte(metadata.AlternateInfo)
}
_, err = s.db.UpdateMediaItem(ctx, database.UpdateMediaItemParams{
ID: mediaItemID,
Title: metadata.Title,
@@ -2284,6 +2548,23 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
Volume: pgtype.Int4{Int32: metadata.Volume, Valid: metadata.Volume > 0},
Imprint: pgtype.Text{String: metadata.Imprint, Valid: metadata.Imprint != ""},
AgeRating: pgtype.Text{String: metadata.AgeRating, Valid: metadata.AgeRating != ""},
WebUrl: pgtype.Text{String: metadata.WebURL, Valid: metadata.WebURL != ""},
MetadataNotes: pgtype.Text{String: metadata.MetadataNotes, Valid: metadata.MetadataNotes != ""},
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
StoryArc: pgtype.Text{String: metadata.StoryArc, Valid: metadata.StoryArc != ""},
IsBlackAndWhite: pgtype.Bool{Bool: metadata.IsBlackAndWhite, Valid: metadata.IsBlackAndWhite},
AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
})
return err
}
@@ -2304,56 +2585,55 @@ func (s *MediaScanner) getMimeType(path string) string {
}
func (s *MediaScanner) WatchChanges(ctx context.Context) error {
// Prevent duplicate calls
if !s.watching.CompareAndSwap(false, true) {
return fmt.Errorf("already watching")
}
// Reset flag when context is cancelled
go func() {
<-ctx.Done()
s.watching.Store(false)
}()
// Perform initial scan of all root folders
go s.performInitialScan(ctx)
// Start directory processor
go s.processDirtyDirectories(ctx)
// Start polling fallback
go s.StartPolling(ctx)
go s.startBackupScan(ctx)
// Handle fsnotify events - queue them for debouncing
go func() {
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
for {
select {
case event, ok := <-s.watcher.Events:
if !ok {
fmt.Printf("[WATCHER] Event channel closed\n")
return
}
// Handle new directories - add them to the watcher
if event.Has(fsnotify.Create) {
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
if err := s.watcher.Add(event.Name); err != nil {
fmt.Printf("Warning: failed to watch new directory %s: %v\n", event.Name, err)
fmt.Printf("[WATCHER] Warning: failed to watch new directory %s: %v\n", event.Name, err)
} else {
fmt.Printf("[WATCHER] Now watching new directory: %s\n", event.Name)
}
}
}
// Mark directory dirty for ANY file change
if event.Has(fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Chmod | fsnotify.Rename) {
if event.Has(fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename) {
fmt.Printf("[WATCHER] Event: %s on %s\n", event.Op, event.Name)
s.markDirectoryDirty(filepath.Dir(event.Name))
}
case err, ok := <-s.watcher.Errors:
if !ok {
fmt.Printf("[WATCHER] Error channel closed\n")
return
}
fmt.Printf("Watcher error: %v\n", err)
fmt.Printf("[WATCHER] Error: %v\n", err)
case <-ctx.Done():
fmt.Printf("[WATCHER] Event loop stopped\n")
return
}
}
@@ -2431,8 +2711,6 @@ func (s *MediaScanner) processDirtyDirectories(ctx context.Context) {
now := time.Now()
readyDirs := make([]string, 0)
// Find directories that haven't been modified in 10 seconds
// This batches changes together (Audiobookshelf approach)
for dirPath, lastChange := range s.dirtyDirs {
if now.Sub(lastChange) >= 10*time.Second {
readyDirs = append(readyDirs, dirPath)
@@ -2442,30 +2720,23 @@ func (s *MediaScanner) processDirtyDirectories(ctx context.Context) {
s.dirtyDirsMu.Unlock()
// Process all ready directories in a batch via job queue
// Job queue serializes scans - prevents concurrent directory access
if len(readyDirs) > 0 {
for _, dirPath := range readyDirs {
// Create directory scan job with correct params for processDirectoryScanJob()
job := &Job{
ID: uuid.New().String(),
Type: JobTypeDirectoryScan,
Params: map[string]any{
"directory": dirPath,
"db": s.db,
},
Status: JobStatusPending,
}
if len(readyDirs) == 0 {
continue
}
// Enqueue via global worker singleton
if WorkerInstance != nil {
WorkerInstance.Enqueue(job)
fmt.Printf("Enqueued directory scan job: %s\n", dirPath)
} else {
fmt.Printf("Warning: Worker not initialized, skipping directory scan: %s\n", dirPath)
affectedRoots := make(map[string]bool)
for _, dirPath := range readyDirs {
for _, folder := range s.folders {
if strings.HasPrefix(dirPath, folder) {
affectedRoots[folder] = true
break
}
}
}
for rootFolder := range affectedRoots {
s.enqueueLibraryScan(rootFolder)
}
}
}
}
@@ -2564,7 +2835,7 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
for _, folder := range s.folders {
if strings.HasPrefix(dirPath, folder) {
rootFolder = folder
if lib, err := s.db.GetLibraryByFolder(ctx, folder); err == nil {
if lib, err := s.db.GetLibraryByFolderPathPrefix(ctx, dirPath); err == nil {
libraryID = lib.LibraryID
break
}
@@ -2576,15 +2847,12 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
return
}
// Walk directory and process new files
// Walk directory and process new files (recurses into subdirectories)
if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if path != dirPath {
return filepath.SkipDir
}
return nil
}
if !s.isScannableFile(path) {
@@ -2601,7 +2869,7 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
LibraryID: libraryID,
})
if err == pgx.ErrNoRows {
if errors.Is(err, pgx.ErrNoRows) {
if _, err := s.processMediaFile(ctx, path); err != nil {
s.errors++
} else {
@@ -2621,36 +2889,34 @@ func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
func (s *MediaScanner) performInitialScan(ctx context.Context) {
fmt.Printf("Performing initial scan of root folders...\n")
for _, folder := range s.folders {
select {
case <-ctx.Done():
fmt.Printf("Initial scan cancelled\n")
return
default:
}
// Skip if folder doesn't exist
if _, err := os.Stat(folder); os.IsNotExist(err) {
fmt.Printf("Skipping nonexistent folder: %s\n", folder)
continue
}
if s.defaultLibraryID.Valid && s.adminID.Valid {
folderPaths := s.folders
libraryIDStr := uuid.UUID(s.defaultLibraryID.Bytes).String()
adminIDStr := uuid.UUID(s.adminID.Bytes).String()
// Submit scan job to worker (non-blocking)
job := &Job{
ID: uuid.New().String(),
Type: JobTypeDirectoryScan,
ID: uuid.New().String(),
Type: JobTypeScan,
Status: JobStatusPending,
UserID: adminIDStr,
Context: context.Background(),
Params: map[string]any{
"directory": folder,
"db": s.db,
"library_id": libraryIDStr,
"folders": folderPaths,
"admin_id": adminIDStr,
"db": s.db,
"force": false,
},
Status: JobStatusPending,
}
if WorkerInstance != nil {
WorkerInstance.Enqueue(job)
fmt.Printf("Enqueued initial scan job: %s\n", folder)
fmt.Printf("Enqueued initial library scan job\n")
} else {
fmt.Printf("Warning: Worker not initialized, skipping initial scan: %s\n", folder)
fmt.Printf("Warning: Worker not initialized, skipping initial scan\n")
}
} else {
fmt.Printf("Warning: no library/admin ID set, skipping initial scan\n")
}
fmt.Printf("Initial scan jobs enqueued\n")
@@ -2696,13 +2962,13 @@ func (s *MediaScanner) Close() error {
return nil
}
func (s *MediaScanner) StartPolling(ctx context.Context) {
func (s *MediaScanner) startBackupScan(ctx context.Context) {
interval := s.GetPollInterval()
if interval <= 0 {
fmt.Println("Polling fallback disabled (interval = 0")
fmt.Println("[BACKUP-SCAN] Periodic scan disabled (interval = 0)")
return
}
fmt.Printf("Polling fallback started with interval: %v\n", interval)
fmt.Printf("[BACKUP-SCAN] Periodic scan started with interval: %v\n", interval)
for {
ticker := time.NewTicker(interval)
@@ -2710,93 +2976,21 @@ func (s *MediaScanner) StartPolling(ctx context.Context) {
select {
case <-ctx.Done():
fmt.Println("Polling fallback stopped")
fmt.Println("[BACKUP-SCAN] Periodic scan stopped")
return
case <-ticker.C:
//Re-read interval each tick for dynamic updates
interval = s.GetPollInterval()
fmt.Printf("Running polling fallback sync (interval: %v)...\n", interval)
if err := s.SyncFilesystemWithDatabase(ctx); err != nil {
fmt.Printf("Polling sync error: %v\n", err)
if !s.GetAutoScanEnabled() {
continue
}
fmt.Printf("[BACKUP-SCAN] Running periodic full scan (interval: %v)...\n", interval)
for _, folder := range s.folders {
s.enqueueLibraryScan(folder)
}
}
}
}
func (s *MediaScanner) SyncFilesystemWithDatabase(ctx context.Context) error {
fmt.Println("[POLL-SYNC] Starting filesystem sync with database")
for _, folder := range s.folders {
lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil {
fmt.Printf("[POLL-SYNC] Warning: failed to get library for folder %s: %v\n", folder, err)
continue
}
libraryID := lib.LibraryID
// Get all media items from database for this library
dbItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID)
if err != nil {
fmt.Printf("[POLL-SYNC] Warning: failed to get library items: %v\n", err)
continue
}
// Build set of existing file paths from filesystem
existingPaths := make(map[string]bool)
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !d.IsDir() && s.isScannableFile(path) {
existingPaths[s.getRelativePath(path)] = true
}
return nil
}); err != nil {
fmt.Printf("[POLL-SYNC] Warning: failed to walk directory %s: %v\n", folder, err)
continue
}
// Check for orphaned items (in DB but not on filesystem)
for _, item := range dbItems {
if item.FilePath != "" && !existingPaths[item.FilePath] {
msg := fmt.Sprintf("[POLL-SYNC] Orphaned media item found: ID=%s, Title=%s, Path=%s",
item.ID, item.Title, item.FilePath)
s.logger.LogDelete(msg)
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
errMsg := fmt.Sprintf("[POLL-SYNC] ERROR: failed to delete orphaned item %s: %v", item.Title, err)
s.logger.LogDelete(errMsg)
s.logger.LogError(errMsg)
} else {
s.logger.LogDelete(fmt.Sprintf("[POLL-SYNC] SUCCESS: deleted orphaned item '%s'", item.Title))
}
}
}
// Check for new files (on filesystem but not in DB)
// This is expensive, so we just check a few representative files
// The fsnotify handler should catch most new files
for relPath := range existingPaths {
// Check if this file exists in DB
_, err := s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
FilePath: relPath,
LibraryID: libraryID,
})
if err == pgx.ErrNoRows {
// New file found - scan it
absPath := folder + "/" + relPath
if _, err := os.Stat(absPath); err == nil {
fmt.Printf("[POLL-SYNC] New file detected, scanning: %s\n", absPath)
if _, err := s.processMediaFile(ctx, absPath); err != nil {
fmt.Printf("[POLL-SYNC] Error scanning new file %s: %v\n", absPath, err)
}
}
}
}
}
fmt.Println("[POLL-SYNC] Filesystem sync completed")
return nil
}
// ============================================
// SCANNER ENHANCEMENTS
// ============================================
// calculateFileSHA256 calculates SHA-256 hash using streaming to avoid loading entire file into memory
func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -2844,19 +3038,19 @@ func (s *MediaScanner) extractOPFIdentifiers(epubPath string) (opfIdentifier, op
return "", "", "low", nil
}
var identifier, uuid string
var identifier, uuidString string
for _, id := range identifiers {
id = strings.TrimSpace(id)
// Check for UUID format (urn:uuid:)
if trimmed, found := strings.CutPrefix(strings.ToLower(id), "urn:uuid:"); found {
uuid = trimmed
uuidString = trimmed
continue
}
// Check if it's a plain UUID (8-4-4-4-12 format)
if isValidUUID(id) {
uuid = id
uuidString = id
continue
}
@@ -2875,9 +3069,9 @@ func (s *MediaScanner) extractOPFIdentifiers(epubPath string) (opfIdentifier, op
}
}
confidence = s.determineHashConfidence(uuid, identifier)
confidence = s.determineHashConfidence(uuidString, identifier)
return identifier, uuid, confidence, nil
return identifier, uuidString, confidence, nil
}
// isValidUUID checks if string is a valid UUID (8-4-4-4-12 format)
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
@@ -73,7 +74,7 @@ func TestCalculateFileSHA256LargeFile(t *testing.T) {
buf := make([]byte, 4096)
for {
n, err := file.Read(buf)
if err != nil && err != bufio.ErrBufferFull {
if err != nil && !errors.Is(err, bufio.ErrBufferFull) {
if err == io.EOF {
break
}
@@ -12,8 +12,8 @@ func TestMediaScanner_GetPollInterval(t *testing.T) {
settingsCache: NewSettingsCache(30 * time.Second),
}
interval := scanner.GetPollInterval()
if interval != 60*time.Second {
t.Errorf("expected 60s, got %v", interval)
if interval != 5*time.Minute {
t.Errorf("expected 5m, got %v", interval)
}
})
}
+29 -30
View File
@@ -1,23 +1,21 @@
package services
import (
"bookhoard/internal/database"
"context"
"os"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper function to setup test database
func setupTestDB(t *testing.T) *database.Queries {
// Use existing test database setup
// This would connect to the test database
return &database.Queries{} // Placeholder - use your actual test DB setup
return &database.Queries{}
}
func TestMarkDirectoryDirty(t *testing.T) {
db := setupTestDB(t)
scanner := NewMediaScanner(db)
@@ -28,6 +26,7 @@ func TestMarkDirectoryDirty(t *testing.T) {
scanner.dirtyDirsMu.RUnlock()
assert.True(t, exists, "Directory should be marked dirty")
}
func TestMarkDirectoryDirty_IgnoresNonWatchedPaths(t *testing.T) {
db := setupTestDB(t)
scanner := NewMediaScanner(db)
@@ -38,17 +37,16 @@ func TestMarkDirectoryDirty_IgnoresNonWatchedPaths(t *testing.T) {
scanner.dirtyDirsMu.RUnlock()
assert.False(t, exists, "Non-watched directory should be ignored")
}
func TestMarkDirectoryDirty_SmartEventMerging(t *testing.T) {
db := setupTestDB(t)
scanner := NewMediaScanner(db)
scanner.folders = []string{"/test/folder"}
// Mark subdirectory first
scanner.markDirectoryDirty("/test/folder/subdir1")
scanner.dirtyDirsMu.RLock()
_, exists1 := scanner.dirtyDirs["/test/folder/subdir1"]
scanner.dirtyDirsMu.RUnlock()
assert.True(t, exists1)
// Mark parent directory - should replace subdirectory
scanner.markDirectoryDirty("/test/folder")
scanner.dirtyDirsMu.RLock()
_, parentExists := scanner.dirtyDirs["/test/folder"]
@@ -57,38 +55,34 @@ func TestMarkDirectoryDirty_SmartEventMerging(t *testing.T) {
assert.True(t, parentExists, "Parent should exist")
assert.False(t, childExists, "Child should be removed (consolidated)")
}
func TestWaitForFileStability_StableFile(t *testing.T) {
db := setupTestDB(t)
scanner := NewMediaScanner(db)
// Create a stable file
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "stable.epub")
err := os.WriteFile(filePath, []byte("test content"), 0644)
require.NoError(t, err)
// Should return true immediately (file already stable)
assert.True(t, scanner.waitForFileStability(filePath))
}
func TestWaitForFileStability_UnstableFile(t *testing.T) {
db := setupTestDB(t)
scanner := NewMediaScanner(db)
// Create a file
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "unstable.epub")
file, err := os.Create(filePath)
require.NoError(t, err)
defer file.Close()
// Start stability check in background
stableChan := make(chan bool)
go func() {
stableChan <- scanner.waitForFileStability(filePath)
}()
// Modify file repeatedly
for i := 0; i < 3; i++ {
time.Sleep(100 * time.Millisecond)
file.WriteString("more data\n")
}
file.Close()
// Should eventually return true
select {
case stable := <-stableChan:
assert.True(t, stable)
@@ -96,27 +90,32 @@ func TestWaitForFileStability_UnstableFile(t *testing.T) {
t.Fatal("waitForFileStability timeout")
}
}
func TestProcessDirtyDirectories_BatchesScans(t *testing.T) {
func TestProcessDirtyDirectories_CollectsReadyDirs(t *testing.T) {
db := setupTestDB(t)
scanner := NewMediaScanner(db)
scanner.folders = []string{"/test/folder"}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// Mark directory dirty multiple times rapidly
for i := 0; i < 5; i++ {
scanner.markDirectoryDirty("/test/folder/subdir")
time.Sleep(100 * time.Millisecond)
scanner.dirtyDirsMu.Lock()
scanner.dirtyDirs["/test/folder/subdir"] = time.Now().Add(-15 * time.Second)
scanner.dirtyDirsMu.Unlock()
scanner.dirtyDirsMu.Lock()
now := time.Now()
readyDirs := make([]string, 0)
for dirPath, lastChange := range scanner.dirtyDirs {
if now.Sub(lastChange) >= 10*time.Second {
readyDirs = append(readyDirs, dirPath)
delete(scanner.dirtyDirs, dirPath)
}
}
go scanner.processDirtyDirectories(ctx)
// Should wait 10 seconds before processing
scanner.dirtyDirsMu.Unlock()
assert.Equal(t, 1, len(readyDirs), "Should find one ready directory")
assert.Equal(t, "/test/folder/subdir", readyDirs[0])
scanner.dirtyDirsMu.RLock()
count := len(scanner.dirtyDirs)
scanner.dirtyDirsMu.RUnlock()
assert.Equal(t, 1, count, "Directory should still be in dirty list")
// Wait for batch to complete
time.Sleep(15 * time.Second)
scanner.dirtyDirsMu.RLock()
count = len(scanner.dirtyDirs)
scanner.dirtyDirsMu.RUnlock()
assert.Equal(t, 0, count, "All dirty directories should be processed after 10s")
assert.Equal(t, 0, count, "Ready directory should be removed from dirty list")
}
+5 -4
View File
@@ -105,9 +105,10 @@ func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUI
metadataBytes, err := json.Marshal(result)
if err == nil {
// Update media item with chapter metadata
// This would require a new query in database/queries.sql
_ = metadataBytes
_, _ = s.db.UpdateMediaItemChapterMetadata(ctx, database.UpdateMediaItemChapterMetadataParams{
ID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
ChapterMetadata: metadataBytes,
})
}
return chapters, nil
@@ -383,7 +384,7 @@ func (s *ReaderService) UpdateSettings(
// Update in database
_, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
SettingValue: []byte(settingsJSON),
SettingValue: settingsJSON,
})
return err
+5 -4
View File
@@ -3,6 +3,7 @@ package services
import (
"bookhoard/internal/database"
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
@@ -110,9 +111,9 @@ type FieldSearchParams struct {
// FieldValue represents a single field value with metadata
type FieldValue struct {
Value string
Count int64
Score float64
Value string `json:"value"`
Count int64 `json:"count"`
Score float64 `json:"score"`
}
// SearchFieldValues handles field-specific search for autocomplete dropdowns
@@ -214,7 +215,7 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
// Shared between JSON endpoint and HTML rendering
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
results, err := s.SearchMediaItemsUnified(ctx, params)
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
}
+140
View File
@@ -0,0 +1,140 @@
package services
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type SeriesInfo struct {
Name string
BookCount int64
TotalInSeries int
CoverPaths []string
LastEntryAt string
}
type SeriesService struct {
db *database.Queries
}
func NewSeriesService(db *database.Queries) *SeriesService {
return &SeriesService{db: db}
}
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
totalCount, err := s.db.GetDistinctSeriesCount(ctx, libraryID)
if err != nil {
return nil, 0, err
}
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
})
if err != nil {
return nil, 0, err
}
var series []SeriesInfo
for _, row := range rows {
seriesName := row.Series.String
coverPaths, _ := s.GetSeriesCovers(ctx, libraryID, seriesName, 7)
totalInSeries := 0
if row.TotalInSeries != nil {
if v, ok := row.TotalInSeries.(int32); ok {
totalInSeries = int(v)
} else if v, ok := row.TotalInSeries.(int64); ok {
totalInSeries = int(v)
}
}
lastEntry := ""
if row.LastEntryAt != nil {
switch v := row.LastEntryAt.(type) {
case pgtype.Timestamptz:
if v.Valid {
lastEntry = v.Time.String()
}
case string:
lastEntry = v
}
}
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
totalInSeries = int(row.BookCount)
}
series = append(series, SeriesInfo{
Name: seriesName,
BookCount: row.BookCount,
TotalInSeries: totalInSeries,
CoverPaths: coverPaths,
LastEntryAt: lastEntry,
})
}
if series == nil {
series = []SeriesInfo{}
}
return series, int(totalCount), nil
}
func (s *SeriesService) GetSeriesCovers(ctx context.Context, libraryID pgtype.UUID, seriesName string, limit int) ([]string, error) {
covers, err := s.db.GetSeriesCovers(ctx, database.GetSeriesCoversParams{
LibraryID: libraryID,
Series: pgtype.Text{String: seriesName, Valid: true},
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
if err != nil {
return nil, err
}
paths := make([]string, 0, len(covers))
for _, c := range covers {
resolved := utils.ResolveMediaURL(c.LibraryID, c.CoverImagePath)
if resolved != "" {
paths = append(paths, resolved)
}
}
return paths, nil
}
func (s *SeriesService) GetSeriesBooks(ctx context.Context, seriesName string) ([]database.MediaItems, error) {
return s.db.GetSeriesBooks(ctx, pgtype.Text{String: seriesName, Valid: true})
}
func continueSeriesRowToMediaItems(row database.GetContinueSeriesItemsRow) database.MediaItems {
return database.MediaItems{
ID: row.ID, LibraryID: row.LibraryID, Title: row.Title, Author: row.Author,
Isbn: row.Isbn, Description: row.Description, FilePath: row.FilePath,
FileSize: row.FileSize, MimeType: row.MimeType, CoverImagePath: row.CoverImagePath,
Series: row.Series, SeriesNumber: row.SeriesNumber, Tags: row.Tags, Asin: row.Asin,
DatePublished: row.DatePublished, Publisher: row.Publisher, Contributors: row.Contributors,
Language: row.Language, Edition: row.Edition, PageCount: row.PageCount,
Genre: row.Genre, CopyrightYear: row.CopyrightYear, GoodreadsID: row.GoodreadsID,
OpenlibraryID: row.OpenlibraryID, GoogleBooksID: row.GoogleBooksID,
AddedByAdminID: row.AddedByAdminID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
FormatGroup: row.FormatGroup, FormatMimetype: row.FormatMimetype,
IsReflowable: row.IsReflowable, HasFixedLayout: row.HasFixedLayout,
TotalCharacters: row.TotalCharacters, ChapterCount: row.ChapterCount,
EntitlementID: row.EntitlementID, RevisionNumber: row.RevisionNumber,
KoboContentID: row.KoboContentID, KoboMetadata: row.KoboMetadata,
MangaType: row.MangaType, ReadingDirection: row.ReadingDirection,
SeriesCount: row.SeriesCount, Volume: row.Volume, Imprint: row.Imprint,
AgeRating: row.AgeRating, WebUrl: row.WebUrl, StoryArc: row.StoryArc,
IsBlackAndWhite: row.IsBlackAndWhite, MetadataNotes: row.MetadataNotes,
CommunityRating: row.CommunityRating, AlternateInfo: row.AlternateInfo,
ScanInformation: row.ScanInformation, Summary: row.Summary,
ChapterMetadata: row.ChapterMetadata, LibraryTypeName: row.LibraryTypeName,
TagsSearch: row.TagsSearch, ContributorsSearch: row.ContributorsSearch,
FileSha256: row.FileSha256, OpfIdentifier: row.OpfIdentifier,
OpfUuid: row.OpfUuid, HashConfidence: row.HashConfidence,
}
}
+136
View File
@@ -0,0 +1,136 @@
package services
import (
"bookhoard/internal/database"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
itemUUID := uuid.New()
libUUID := uuid.New()
adminUUID := uuid.New()
row := database.GetContinueSeriesItemsRow{
ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Test Book",
Author: pgtype.Text{String: "Test Author", Valid: true},
Isbn: pgtype.Text{String: "978-1234567890", Valid: true},
Description: pgtype.Text{String: "A test book", Valid: true},
FilePath: "/books/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
CoverImagePath: pgtype.Text{String: "/covers/test.jpg", Valid: true},
Series: pgtype.Text{String: "Test Series", Valid: true},
SeriesNumber: pgtype.Int4{Int32: 3, Valid: true},
Tags: []string{"fantasy", "adventure"},
FormatGroup: "epub",
AddedByAdminID: pgtype.UUID{Bytes: adminUUID, Valid: true},
}
result := continueSeriesRowToMediaItems(row)
assert.Equal(t, pgtype.UUID{Bytes: itemUUID, Valid: true}, result.ID, "ID should match")
assert.Equal(t, pgtype.UUID{Bytes: libUUID, Valid: true}, result.LibraryID, "LibraryID should match")
assert.Equal(t, "Test Book", result.Title, "Title should match")
assert.Equal(t, pgtype.Text{String: "Test Author", Valid: true}, result.Author, "Author should match")
assert.Equal(t, pgtype.Text{String: "978-1234567890", Valid: true}, result.Isbn, "ISBN should match")
assert.Equal(t, "/books/test.epub", result.FilePath, "FilePath should match")
assert.Equal(t, pgtype.Text{String: "application/epub+zip", Valid: true}, result.MimeType, "MimeType should match")
assert.Equal(t, pgtype.Text{String: "/covers/test.jpg", Valid: true}, result.CoverImagePath, "CoverImagePath should match")
assert.Equal(t, pgtype.Text{String: "Test Series", Valid: true}, result.Series, "Series should match")
assert.Equal(t, pgtype.Int4{Int32: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
assert.Equal(t, []string{"fantasy", "adventure"}, result.Tags, "Tags should match")
assert.Equal(t, "epub", result.FormatGroup, "FormatGroup should match")
}
func TestContinueSeriesRowToMediaItems_NullFieldsHandled(t *testing.T) {
itemUUID := uuid.New()
libUUID := uuid.New()
row := database.GetContinueSeriesItemsRow{
ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "No Metadata Book",
Author: pgtype.Text{Valid: false},
Isbn: pgtype.Text{Valid: false},
Description: pgtype.Text{Valid: false},
FilePath: "/books/nometa.epub",
FileSize: pgtype.Int8{Valid: false},
MimeType: pgtype.Text{Valid: false},
CoverImagePath: pgtype.Text{Valid: false},
Series: pgtype.Text{Valid: false},
SeriesNumber: pgtype.Int4{Valid: false},
Tags: nil,
FormatGroup: "epub",
}
result := continueSeriesRowToMediaItems(row)
assert.Equal(t, "No Metadata Book", result.Title)
assert.False(t, result.Author.Valid, "Author should be invalid/null")
assert.False(t, result.Series.Valid, "Series should be invalid/null")
assert.False(t, result.SeriesNumber.Valid, "SeriesNumber should be invalid/null")
assert.Nil(t, result.Tags, "Tags should be nil")
}
func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
itemUUID := uuid.New()
libUUID := uuid.New()
row := database.GetContinueSeriesItemsRow{
ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Full Book",
Author: pgtype.Text{String: "Author", Valid: true},
Isbn: pgtype.Text{String: "ISBN", Valid: true},
Description: pgtype.Text{String: "Desc", Valid: true},
FilePath: "/book.epub",
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
MimeType: pgtype.Text{String: "epub", Valid: true},
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
Series: pgtype.Text{String: "Series", Valid: true},
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
Tags: []string{"tag1"},
Asin: pgtype.Text{String: "ASIN", Valid: true},
Publisher: pgtype.Text{String: "Pub", Valid: true},
Language: pgtype.Text{String: "en", Valid: true},
Edition: pgtype.Text{String: "1st", Valid: true},
Genre: pgtype.Text{String: "Fiction", Valid: true},
FormatGroup: "epub",
FormatMimetype: pgtype.Text{String: "application/epub+zip", Valid: true},
IsReflowable: pgtype.Bool{Bool: true, Valid: true},
HasFixedLayout: pgtype.Bool{Bool: false, Valid: true},
TotalCharacters: pgtype.Int8{Int64: 500000, Valid: true},
ChapterCount: pgtype.Int4{Int32: 20, Valid: true},
MangaType: pgtype.Text{String: "manga", Valid: true},
ReadingDirection: pgtype.Text{String: "rtl", Valid: true},
SeriesCount: pgtype.Int4{Int32: 10, Valid: true},
Volume: pgtype.Int4{Int32: 1, Valid: true},
LibraryTypeName: pgtype.Text{String: "ebooks", Valid: true},
FileSha256: pgtype.Text{String: "abc123", Valid: true},
}
result := continueSeriesRowToMediaItems(row)
assert.Equal(t, pgtype.Text{String: "ASIN", Valid: true}, result.Asin)
assert.Equal(t, pgtype.Text{String: "Pub", Valid: true}, result.Publisher)
assert.Equal(t, pgtype.Text{String: "en", Valid: true}, result.Language)
assert.Equal(t, pgtype.Text{String: "1st", Valid: true}, result.Edition)
assert.Equal(t, pgtype.Text{String: "Fiction", Valid: true}, result.Genre)
assert.Equal(t, pgtype.Text{String: "application/epub+zip", Valid: true}, result.FormatMimetype)
assert.Equal(t, pgtype.Bool{Bool: true, Valid: true}, result.IsReflowable)
assert.Equal(t, pgtype.Bool{Bool: false, Valid: true}, result.HasFixedLayout)
assert.Equal(t, pgtype.Int8{Int64: 500000, Valid: true}, result.TotalCharacters)
assert.Equal(t, pgtype.Int4{Int32: 20, Valid: true}, result.ChapterCount)
assert.Equal(t, pgtype.Text{String: "manga", Valid: true}, result.MangaType)
assert.Equal(t, pgtype.Text{String: "rtl", Valid: true}, result.ReadingDirection)
assert.Equal(t, pgtype.Int4{Int32: 10, Valid: true}, result.SeriesCount)
assert.Equal(t, pgtype.Int4{Int32: 1, Valid: true}, result.Volume)
assert.Equal(t, pgtype.Text{String: "ebooks", Valid: true}, result.LibraryTypeName)
assert.Equal(t, pgtype.Text{String: "abc123", Valid: true}, result.FileSha256)
}
+19 -4
View File
@@ -221,7 +221,8 @@ func (w *Worker) processJob(job *Job) {
}
w.mu.Unlock()
job.StartedAt = new(time.Now())
started := time.Now()
job.StartedAt = &started
w.mu.Lock()
if result, exists := w.results[job.ID]; exists {
@@ -253,7 +254,8 @@ func (w *Worker) processJob(job *Job) {
err = fmt.Errorf("unknown job type: %s", job.Type)
}
job.CompletedAt = new(time.Now())
completed := time.Now()
job.CompletedAt = &completed
job.Error = err
job.Result = result
@@ -303,6 +305,19 @@ func (w *Worker) processJob(job *Job) {
NewItems: newItems,
Errors: errors,
}
if job.Type == JobTypeScan && w.connManager != nil && job.UserID != "" {
w.connManager.BroadcastToUser(job.UserID, wsync.BroadcastMessage{
Type: wsync.MessageTypeScanComplete,
Data: map[string]interface{}{
"job_id": job.ID,
"files_scanned": filesScanned,
"new_items": newItems,
"errors": errors,
},
})
}
w.mu.Unlock()
}
@@ -886,9 +901,9 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
// Create temporary scanner instance for this job
scanner := NewMediaScanner(db)
scanner.job = job
// Find which library owns this directory
// Find which library owns this directory (prefix match for subdirectories)
ctx := context.Background()
libRow, err := db.GetLibraryByFolder(ctx, directory)
libRow, err := db.GetLibraryByFolderPathPrefix(ctx, directory)
if err != nil {
return nil, fmt.Errorf("directory not associated with any library: %s", directory)
}

Some files were not shown because too many files have changed in this diff Show More