Author SHA1 Message Date
john-okeefe e7c4c931ee ci(release): add tag-triggered image build & push to Gitea registry
Release / build-and-push (push) Failing after 3m56s
Adds .gitea/workflows/release.yml. On a v* git tag push (or manual dispatch), builds the Dockerfile and publishes to git.linuxhg.com/bookhoard/bookhoard under two tags: the version (${{ gitea.ref_name }}) and 'latest'. Auth uses the auto-provided GITHUB_TOKEN; no secret to manage. Pushes to main do nothing, so work-in-progress commits never ship.
2026-07-29 16:53:43 -04:00
john-okeefe bac84e24ec docs(env): document DB_PORT, SERVER_PORT, BASE_URL, COOKIE_SECURE, IMAGE_TAG in .env.example
Expose the recently-added env-driven compose settings as commented examples so self-hosters and deployers can discover them. All remain optional with defaults.
2026-07-29 16:12:21 -04:00
john-okeefe de8f71b2be refactor(compose): make app/db ports configurable via DB_PORT and SERVER_PORT
Replace hardcoded port literals with env-driven variables so a single change
in .env reconfigures the full stack consistently. Defaults are unchanged
(DB 5432, app 8765), so existing setups need no .env changes.

- DB_PORT (default 5432): drives the db host<->container port mapping,
  Postgres PGPORT (so it listens on the chosen port), and the app's
  DATABASE_PORT connection setting. Lets deployers avoid a host port conflict
  (e.g. another local Postgres) by setting DB_PORT once.
- SERVER_PORT (default 8765): drives the app host<->container mapping, the
  SERVER_PORT the app listens on, and the healthcheck target URL.
- Applied to both the base (docker-compose.yml) and the dev override
  (docker-compose.dev.yml, tests service) so dev and prod stay in sync.
2026-07-29 16:11:01 -04:00
john-okeefe 1129fcae6f fix(compose): make BASE_URL/COOKIE_SECURE configurable, drop obsolete version
Address compose issues surfaced on first production deploy:

- Remove obsolete `version: "3.8"` (ignored by Compose v2; caused a warning).
- Fix BASE_URL: it used compose-time interpolation of ${SERVER_PORT}, which is
  only defined as a runtime container env var (invisible to interpolation) and
  absent from .env. This resolved to an empty string, producing a broken
  `http://localhost:` (no port) and a startup warning. Now
  ${BASE_URL:-http://localhost:8765}, overridable per-deployment via .env.
- Move COOKIE_SECURE from the db service to the app service and make it
  configurable (${COOKIE_SECURE:-false}). It controls the session cookie Secure
  flag, an app concern; on the db service it was a no-op, so the app never
  received it and cookies were always non-secure. Set COOKIE_SECURE=true behind
  a TLS-terminating reverse proxy (Caddy/nginx/traefik), where the app speaks
  plain HTTP internally.
- Image reference unchanged: ${IMAGE_TAG:-latest} (no hardcoded version).
2026-07-29 16:02:32 -04:00
john-okeefe 76c6826920 feat(deploy): split compose into prod base + dev override
Restructure the container setup to support registry-based deployment:
the default docker-compose.yml now pulls a prebuilt app image from the
Gitea container registry instead of building locally, while a new
docker-compose.dev.yml override preserves the local build + integration
test workflow for development.

Why:
- Production and self-hosting should consume a published image, not
  rebuild from source on the host. The default `docker compose up` now
  pulls the app image (git.linuxhg.com/bookhoard/bookhoard) alongside the
  public postgres image, with no build step required.
- Development still needs to build from source and run integration
  tests, so those concerns move to an override file the Makefile applies.
  Shared config (env, volumes, ports, healthchecks) lives in one place to
  avoid drift between environments.

Changes:
- docker-compose.yml (prod base): the app service now references
  `image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}` instead
  of a build context. The tests service is removed (moved to the
  override). IMAGE_TAG lets deployers pin or roll back a specific version.
- docker-compose.dev.yml (new override): adds the local `build:` context
  for the app and defines the integration `tests` service (profile-gated).
  Everything else is inherited from the base file via compose merging.
- Makefile: introduce a COMPOSE variable that merges the base and
  override (`-f docker-compose.yml -f docker-compose.dev.yml`); all dev
  targets now use it. Plain `docker compose` against the base file only
  remains the production path.
- README: quickstart updated to pull and start prebuilt images; clone URL
  points at the Gitea instance.

The development workflow (`make rebuild-app`, `make test-integration`,
etc.) is functionally unchanged.
2026-07-29 15:47:40 -04:00
john-okeefe 75b33fdae6 feat(sync): wire annotation sync into all device and web handlers
Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.

INGEST (device → server):

KOReader (koreader.go):
  - Add processBookAnnotations helper that processes inline highlights,
    notes, and bookmarks from every progress push (immediate + checkpoint)
  - Highlights get CRE→CFI position conversion before SaveHighlight
  - KOReader 'notes' (text + notes) stored as highlights with NoteText
    to ensure correct round-trip classification
  - Bookmarks routed through SaveBookmark with device sync data
  - Called from both updateProgressForBook and handleCheckpointSync

Kobo (kobo.go):
  - Markup handler: annotations and bookmarks route through
    AnnotationService (SaveHighlight/SaveBookmark)
  - Bookmark handler: same routing with device sync data
  - SyncFromServer handler: same routing
  - All handlers fall back to direct DB calls when annotationSvc == nil

Web reader (media.go):
  - CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
  - CreateMediaNote → SaveNote (Source="web")
  - DeleteMediaHighlight → TombstoneHighlightByID
  - DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
  - All fall back to old behavior when annotationSvc == nil

SERVE (server → device):

KOReader GetMetadata (koreader.go):
  - Query and serve bookmarks from media_bookmarks table (was missing)
  - Serve deleted_highlights and deleted_bookmarks arrays containing
    device_sync_data + dedup_key for client-side deletion
  - Highlights/notes already served with reverse CFI conversion

Kobo Markup handler (kobo.go):
  - Track processed books during sync
  - Query tombstones per book, extract bookmark_id from device_sync_data
  - Return DeletedAnnotations array in KoboSyncStatus response

Conflict resolution (conflicts.go):
  - Enable annotation conflict types in ResolveConflict handler
  - Add applyAnnotationResolution dispatching to:
    applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
  - Each looks up by dedup_key and applies winner's fields
  - Allow manual override of auto_resolved conflicts
    (changed check from != "unresolved" to == "user_resolved")

Infrastructure:
  - AnnotationService field + SetAnnotationService in router Config
  - Inject AnnotationService into KOReader, Kobo, Media handlers
  - Start tombstone purger goroutine in main.go (24h interval)
  - Test helpers: construct AnnotationService in test setup
2026-07-29 14:49:19 -04:00
john-okeefe 635a9439cb feat(sync): implement annotation support in sync queue processor
Wire AnnotationService into SyncQueueProcessor and implement the three
previously-stubbed execute methods:

- syncHighlight: unmarshals syncData JSON into SaveHighlightRequest,
  applies CRE→CFI conversion via AnnotationService
- syncNote: unmarshals into SaveNoteRequest
- syncBookmark: unmarshals into SaveBookmarkRequest
- Add SyncTypeBookmark to executeSync switch (was hitting default error)

Add enqueue methods for future offline/batch use:
- EnqueueHighlight / EnqueueNote / EnqueueBookmark
- Shared enqueueAnnotation helper creates queue items with
  PriorityCriticalNote and 3 max attempts
- Update types (HighlightUpdate, NoteUpdate, BookmarkUpdate) mirror the
  existing ProgressUpdate pattern

Existing handler behavior is unchanged — annotations still sync
synchronously via AnnotationService. The queue path is available for
retry-on-failure and offline batch processing scenarios.
2026-07-29 14:49:01 -04:00
john-okeefe 3b15766149 feat(sync): add AnnotationService with dedup, LWW, and tombstone management
AnnotationService is the central service for cross-device annotation sync.
It provides SaveHighlight, SaveNote, and SaveBookmark methods that handle
the full sync lifecycle:

Identity (3-layer):
  1. Server UUID (primary key)
  2. Per-device native ID stored in device_sync_data JSONB
  3. Content dedup_key: sha1(normalize(selection_text) + bucket_position)
     - CFI character offsets are stripped for bucketing so the same
       highlight at slightly different offsets still deduplicates
     - Raw positions are preserved in the DB for precise restoration

Resolution policy (LWW):
  - When the incoming annotation has an explicit ModifiedAt timestamp,
    last_modified_at wins
  - When the device sends zero ModifiedAt (creation time only), field-diff
    mode compares content fields (text/color/note/percentage) — if all
    match, the save is skipped; if any differ, the save is applied with
    server-receive-time as the new last_modified_at

Conflict detection:
  - When incoming and existing annotations have different sources (e.g.
    koreader vs kobo) and content differs, an auto_resolved sync_conflict
    is recorded with both sides' data for audit trail
  - Broadcasts a WebSocket conflict notification for real-time UI updates

Tombstone management:
  - Delete-wins: tombstoned annotations block recreation from stale pushes
  - 30-day TTL before physical purge
  - PurgeExpiredTombstones method + StartTombstonePurger goroutine (24h ticker)

Add locators.go with unified bidirectional CFI conversion:
  ConvertToCanonical / ConvertFromCanonical
  - CRE XPointer <-> standard EPUB CFI (for KOReader)
  - KEPUB CFI passthrough (for Kobo)
  - Skips non-reflowable formats (PDF, CBZ, fixed-layout EPUBs)

Add 25 unit tests covering:
  - Dedup key determinism, text normalization, position sensitivity
  - Offset insensitivity (CFI char-offset bucketing)
  - Device sync data merge (preserves existing, overwrites same source)
  - Cross-source detection
  - LWW comparison (newer wins, older skipped, fallback to updated_at)
  - Field-diff mode (identical content skipped, changes applied)
  - Tombstone TTL constant
  - CRE XPointer parsing and classification
  - Standard EPUB CFI classification
2026-07-29 14:48:47 -04:00
john-okeefe 2a15effc3e feat(db): add annotation sync schema, queries, and tombstone support
Add migration columns to media_highlights, media_notes, and media_bookmarks
for cross-device annotation sync:

- dedup_key: SHA-1 of normalized selection text + bucketed position, used
  as the stable cross-device identity for annotations
- last_modified_at / last_modified_source: edit clock for LWW resolution
  and cross-source conflict detection
- deleted / deleted_at: sticky tombstone columns for delete-wins semantics
  with a 30-day TTL before rows are physically purged
- note_text on highlights: stores attached notes from KOReader entries that
  have both selected text and a user note
- Location columns on bookmarks (cfi_position, percentage_location,
  epubcfi_location, chapter_reference, paragraph_reference)
- device_sync_data JSONB on all three tables: stores per-device native
  identifiers (e.g. KOReader pos0/datetime, Kobo bookmark_id) so each
  device can locate and manipulate its own copy of an annotation

Add partial unique indexes on (user_id, media_item_id, dedup_key) where
deleted = FALSE to enforce one active annotation per dedup key.

Add tombstone purge indexes on (deleted, deleted_at) for efficient GC.

New queries:
- GetByDedupKey for all three tables (returns active or most-recent tombstone)
- CreateFull / UpdateForSync for all three tables (populate sync columns)
- TombstoneByDedupKey / TombstoneByID for all three tables
- PurgeExpired* for all three tables (GC past TTL)
- GetActiveAnnotationsForBook (filtered union of highlights + notes)
- GetTombstonedAnnotationsForBook (union of all 3 deleted within TTL)
- GetMediaBookmarks with deleted filter
- GetMediaBookmark (singular) with deleted filter
- Added deleted=FALSE filter to GetMediaHighlights, GetAnnotationsForBook
- CreateAutoResolvedSyncConflict (INSERT with resolution_status='auto_resolved')
2026-07-29 14:48:30 -04:00
john-okeefe 78176c57a5 chore(compose): quote numeric env var values
Quote DATABASE_PORT and SERVER_PORT ("5432", "8765") in docker-compose.yml so they are treated as strings rather than YAML integers, avoiding type-coercion warnings from compose runtimes.
2026-07-29 11:08:25 -04:00
john-okeefe 980aaee0d9 refactor(setup): derive setup-complete status from admin user count
Setup completion was previously tracked by a manually-flipped setup_complete row in system_settings, written via a JWT-protected PUT /api/setup/complete endpoint. This meant any admin user created outside the setup wizard (future CLI, seed scripts, direct DB inserts) would not flip the switch, leaving the app stuck redirecting to /setup.

The trigger is now derived from real data: setup is complete iff at least one admin user exists. This is self-correcting regardless of how users are created, and re-engages setup automatically if all admins are ever removed.

Changes:
- Add internal/setupstatus package with IsSetupComplete() (queries CountAdmins, 10s in-memory cache, fails open on DB error) and Invalidate() to clear the cache. Uses an AdminCounter interface to avoid importing the database package.
- Add CountAdmins sqlc query (SELECT COUNT(*) FROM users WHERE role = 'admin') and regenerate.
- Rewire router/setup.go isSetupComplete() to delegate to setupstatus; drop the old setup_complete setting read, cache vars, and the PUT /api/setup/complete route.
- Call setupstatus.Invalidate() in the auth handler after CreateUser, UpdateUserRole, and DeleteUser so the cache reflects admin-count changes immediately.
- Align first-user promotion in Register to key off !adminExists instead of len(users) == 0, so the two checks cannot diverge.
- Remove the now-dead SetSetupComplete/GetSetupStatus handlers.
- Drop the setup_complete seed row from schema.sql.
- Remove the apiPut('/setup/complete') call from the setup wizard finishSetup(); the admin account created in submitAdmin already marks setup complete server-side.
2026-07-29 11:08:18 -04:00
john-okeefe acfb298b74 Add missing btn-secondary CSS class definition
The btn-secondary class was used 39 times across 15 templates but had
no definition in any global CSS file. The only definition existed in
error.templ's inline styles (intentionally self-contained).

Added .btn-secondary and .btn-secondary:hover to input.css using
theme-aware CSS variables (--accent) consistent with the existing
.btn-primary pattern. Rebuilt style.css via Tailwind.
2026-06-08 21:19:18 -04:00
john-okeefe ce3ab8bcbc Fix invalid templ conditional syntax in metadata editor modal
The Manga Type and Reading Direction select options used invalid inline
if syntax: 'if cond { selected }' which is not valid templ. Replaced
with the correct templ attribute syntax: selected={ boolExpression }
which properly renders the selected attribute when true and omits it
when false.

Also add for/id attributes to link labels to their select elements
for accessibility (manga_type and reading_direction).

Affected lines: book_detail_modals.templ:478-496
2026-06-08 21:19:06 -04:00
john-okeefe 6d44ae884c chore(tests): remove debug tests that depend on local file paths
Remove TestConvertHessBook and TestDebugHess (both referenced a
non-existent Hess EPUB at an absolute local path) and
TestConvertCPByTextSearch (referenced a Crime and Punishment EPUB
in the local uploads directory). These were development-time debug
tests that only worked on the author's machine.

All CFI/KEPUB conversion behavior is already covered by the proper
fixture-based tests (TestKEPUBRoundTrip, TestKEPUBConvertKEPUBToStandard,
TestKEPUBConvertWithEmElements, etc.) which use createTestEPUB and
createTestKEPUB helpers.
2026-06-08 19:45:12 -04:00
john-okeefe f6d98dd7cc feat(sync): convert KEPUB CFI to standard and extract context_text on Kobo push
When a Kobo device pushes a last-read-place bookmark, the server now
converts the KEPUB CFI (with koboSpan wrappers) to a standard EPUB CFI
and extracts surrounding text as context_text for use by other devices
(KOReader, web reader) during their pull-side CFI conversions.

Previously the raw KEPUB CFI was stored verbatim as epubcfi, which
meant foliate and CREngine couldn't resolve it (wrong child indices
due to koboSpan wrappers), and no context_text was available for the
text-search fallback in ConvertStandardToCRE.

Changes:
- kepub_cfi_converter.go: Add ExtractedContext field to
  KEPUBConversionResult, populated from the already-computed
  searchText in both ConvertKEPUBCFIToStandard and
  ConvertStandardCFIToKEPUB (exact-match and percentage-fallback
  paths).
- kobo.go: Add libraryService field and SetLibraryService setter
  (mirrors KOReaderHandler pattern). Add convertKoboCFIToStandard
  helper that resolves EPUB+KEPUB paths, instantiates the converter,
  and returns the converted CFI + extracted context. The last-read-place
  branch in Markup now calls this helper for reflowable formats,
  skipping fixed-layout/comic archives (page-index only).
- router.go: Add LibraryService to router Config.
- sync.go: Wire LibraryService to KoboHandler via SetLibraryService.
- main.go: Pass libraryService through router config.

The conversion is purely additive — if no KEPUB file exists on disk
(e.g. side-loaded EPUB without kepubify conversion), the handler
gracefully skips conversion and stores the raw CFI as before.
2026-06-08 19:45:01 -04:00
john-okeefe e584200369 feat(reader): send context_text with progress for reflowable EPUBs
The web reader now captures ~100 chars of visible text from the
foliate relocate event's range and includes it as context_text in
the progress PUT body for reflowable formats.

This enables the server's reverseByTextSearch fallback in
ConvertStandardToCRE, which is critical for single-file EPUBs
(e.g. 1984) where foliate emits coarse or fake-section CFIs that
CREngine cannot directly resolve. Previously only the KOReader
plugin sent context_text; the web reader's omission left the
fallback unusable, causing percentage-based position estimation
that was off by ~1 page.

Changes:
- Add contextText state property (line 387)
- Extract visible text from e.detail.range in relocate handler,
  normalize whitespace, and slice to 100 chars (lines 508-512)
- Include context_text in saveProgress PUT body for reflowable
  EPUBs only, alongside epubcfi (line 575)

The server-side chain was already wired: media.go accepts it,
progress.go stores it, and koreader.go passes it to the CFI
converter. No Go changes needed.
2026-06-07 11:45:33 -04:00
john-okeefe 9bd23fc1d7 fix(reader): stop sending epubcfi for fixed-layout documents
The web reader's saveProgress always sent epubcfi: cfi || "", even
for fixed-layout comics/PDFs. Foliate's comic renderer generates a
fake CFI (epubcfi(/6/{n})) for every page via CFI.fake.fromIndex,
which the server stored as a valid locator. These fake CFIs are
meaningless — the page index is the canonical locator for image-based
content — and they caused koreader to crash on pull (see prior commit).

Only set epubcfi in the request body for reflowable documents.
2026-06-07 00:27:04 -04:00
john-okeefe 762181c123 fix(sync): treat page index as sole locator for fixed-layout pull path
The push path (koreader→server) was already updated to omit epubcfi
for paging documents, and SaveProgress derives percentage from
page/total_pages for fixed formats. However, the pull path
(server→koreader) still returned any stored epubcfi and attempted
CFI→XPointer conversion, and SaveProgress preserved stale CFI values
written by the web reader (which generates fake CFIs via
CFI.fake.fromIndex for every comic/PDF page).

These fake CFI strings lingered in the database and koreader's pull
path picked them up over progress.page, causing tonumber("epubcfi(...)")
→ nil → GotoPage(nil) → crash when the user confirmed the sync prompt.

Changes:
- GetMetadata (koreader.go): for fixed_layout/comic_archive formats,
  skip returning epubcfi and skip the CFI→CRE XPointer conversion.
  Reflowable documents are byte-for-byte unchanged.
- SaveProgress (progress.go): for fixed formats, explicitly clear
  epubcfi and character_offset on every save so stale values from
  prior web-reader sessions are cleaned up over time.
2026-06-07 00:26:53 -04:00
john-okeefe d11a623f9f fix(sync): skip CRE->CFI conversion for image-based fixed formats
The CRE->CFI converter works by text search / character-offset mapping
across the EPUB spine. Image-based fixed content (fixed-layout comic
EPUBs, PDFs, comic archives) has no extractable text, so the conversion
can never succeed and only wastes time parsing content docs while
logging a failed percentage-precision result.

Guard the conversion in the KOReader progress handler: when the matched
media item's FormatGroup is fixed_layout or comic_archive, skip
ConvertCREToStandard entirely. The incoming xpointer is left as-is so
KOReader<->KOReader restore via GotoXPointer still works; the web reader
restores by page index (the canonical locator for these formats).

This covers fixed-layout comic EPUBs in particular: KOReader routes all
EPUBs through CREngine (has_pages == false), so they send a real
xpointer that passes IsCREXPointer and would otherwise trigger the
doomed text extraction. Reflowable EPUBs (format_group == reflowable)
still run the conversion exactly as before.
2026-06-07 00:04:03 -04:00
john-okeefe caaf27427e feat(reader): apply RTL reading direction for fixed-layout manga/comics
foliate's goLeft/goRight (and spread ordering) swap on book.dir ===
"rtl", but makeComicBook never sets dir, so manga/comic archives
always paged left-to-right even when the metadata says right-to-left.

For fixed-layout content, set this.book.dir = "rtl" from the
readingDirection metadata after the book opens. Reflowable EPUBs are
unaffected: they keep whatever direction foliate read from the OPF.

This is scoped to isFixedLayout (FXL EPUB, PDF, comics) so it cannot
reach the reflowable path.
2026-06-07 00:03:54 -04:00
john-okeefe 423a1c0bf7 feat(sync): use page index as canonical locator for fixed-layout & comic formats
Fixed-layout EPUBs, PDFs, DjVu, and comic archives (cbz/cbr/cb7/cbt)
are page-based: each page is a fixed image, so a page index is an exact,
universal locator regardless of screen size or device. Sync previously
treated these like reflowable content (CFI-first restore, percentage
fallback, character-offset math), which was both wrong and lossy. This
makes the page index the canonical position for fixed-layout and comic
formats while leaving the reflowable path byte-for-byte unchanged.

Backend:
- progress.go SaveProgress: branch on mediaItem.FormatGroup. For
  fixed_layout/comic_archive derive percentage from current_page/
  total_pages and skip the CFI/character-offset back-fills (meaningless
  for image content). The reflowable derivation block is preserved
  verbatim under an else.
- kobo.go: Kobo only sends a percentage, so for fixed-layout/comic
  formats derive CurrentPage via PercentageToPage(percentage, pageCount)
  using the media item's known page count, so Kobo->web lands on the
  exact page.

Reader:
- reader.templ readerInitExpr: pass savedPage/savedTotalPages to the
  reader config for fixed_layout/comic_archive formats.
- reader.ts: when isFixedLayout and savedPage is present, restore via
  view.init({ lastLocation: savedPage - 1 }) (a bare number navigates
  foliate directly to the section index). Reflowable falls through to
  the existing CFI->percentage path, unchanged.
2026-06-07 00:03:46 -04:00
john-okeefe b1d2ccc87c feat(opds): serve comic archives in native format with correct mime types
The OPDS device catalog previously hard-coded every acquisition link as
application/epub+zip and always offered kepub/pdf alternate links, which
is wrong for comic archives (cbz/cbr/cb7/cbt) and other non-epub media.

- Resolve the acquisition mime type from the media item's stored
  mime_type (falling back to format_mimetype, then epub) instead of
  assuming epub
- Only offer reflowable conversions (kepub for kobo, pdf) for ebooks;
  comic archives are served as-is in their native format
- Derive the native format label (epub/pdf/cbz/cbr/...) from the file
  path in ListFormats rather than always reporting epub
- Add resolveMimeType, isComicArchive, and formatLabelFromPath helpers
2026-06-07 00:03:18 -04:00
john-okeefe 3170aa6b77 feat(setup): collect server base URL during first-run wizard
Add a Server URL field to the admin-account step of the setup wizard so
the public base URL (used for device sync, OPDS feed, and API endpoints)
is configured up front instead of requiring a later visit to admin
settings.

- setup.templ: base URL input on the admin step plus a summary entry
- setup.ts: default baseUrl to window.location.origin and persist it via
  PUT /system/config ({ base_url }) right after the admin account is
  created, with a non-fatal warning if the save fails
2026-06-07 00:03:01 -04:00
john-okeefe f75d68bf66 feat(ui): add first-run setup wizard with 4 guided steps
Add a single-page multi-step setup wizard that guides new users
through initial configuration:

Step 1 - Admin Registration: Creates the first user (auto-admin)
  using the existing POST /api/auth/register endpoint, with
  real-time password validation and confirmation matching.

Step 2 - Library Creation: Create one or more libraries (Ebooks,
  Audiobooks, Comics, Manga) using POST /api/libraries. Libraries
  list updates inline as they're added.

Step 3 - Folder Configuration: Add filesystem folders to each
  library using the existing GET /api/libraries/browse endpoint for
  a visual directory browser. Folders are attached via POST
  /api/libraries/:id/folders.

Step 4 - Initial Scan: Triggers a manual scan of all libraries via
  POST /api/libraries/scan with real-time progress polling using the
  existing scan status endpoint.

On completion, the wizard calls PUT /api/setup/complete and sets the
selectedLibrary cookie to the first library's UUID, ensuring the
dashboard loads with populated content instead of an empty 'All
Libraries' view. Handles the edge case where a stale JWT from a
previous database instance triggers an 'already exists' error by
auto-advancing to step 2.

The wizard reuses all existing API calls, Alpine.js utilities, and
form validation functions — no backend logic was duplicated.
2026-06-06 00:04:09 -04:00
john-okeefe f37de6c07c feat(router): add setup redirect middleware and setup routes
Add setupRedirectMiddleware that checks the setup_complete system
setting on every request. If setup is incomplete, all non-setup
requests are redirected to /setup so the wizard is the first thing
new users see. The check uses an in-memory cache (10s TTL) to avoid
hitting the database on every request, with cache invalidation on
setup completion.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No changes to existing cfi_converter.go or KOReader conversion code.
Same-package access to unexported functions (resolveCFIToNode, buildCFI,
findTextInNode, etc.) via internal/sync package placement.
2026-06-02 21:21:28 -04:00
john-okeefe 0f225c39b9 fix(templ): correct @click attribute interpolation syntax
Templ requires Go expression interpolation for dynamic attributes,
not string embedding. Change from @click="func('{ id }')" to
@click={ "func('" + id + "')" } for device ID and registration ID
buttons.
2026-06-02 19:46:29 -04:00
john-okeefe bc59159cde feat(ui): inline conflict resolution with Keep This button
Replace the 'Go to Conflicts Page' link with inline conflict resolution.
Each conflict source now has a 'Keep This' button that resolves the
conflict directly from the book detail page.

- Conflict data now keyed by source name (koreader, web) instead of
  new/existing, with Source and Timestamp fields
- Display percentage scaled correctly (* 100)
- Fix page field name from current_page to page
- Add conflict resolution JavaScript in book-detail.ts
- Add 10-minute cooldown after resolution to prevent re-detection
2026-06-02 19:46:17 -04:00
john-okeefe f57d64563b fix(reader): scale percentage correctly, add 5s init guard
- reader.templ: divide DB percentage (0-1) by 100 was wrong; actually
  the router multiplies by 100, so template now divides by 100 to get
  back to 0-1 range for foliate-js savedPercentage
- reader.ts: add 5-second initTime guard to prevent overwriting
  existing progress with initial position on page load
2026-06-02 19:46:03 -04:00
john-okeefe 1f8c3c21ae fix(admin): cascade base_url to opds/api URLs, fix pending registration time format
- UpdateSystemConfiguration: when base_url changes, automatically
  update opds_base_url and api_base_url derived configs
- convertPending: format time.Time as RFC3339 string instead of
  relying on string type assertion which would panic
2026-06-02 19:45:39 -04:00
john-okeefe 5d22021e8e fix(opds): epubcfi Atom compatibility, path deduplication, auth tokens
- Add <title> and <author><name> elements to Atom feed for compatibility
- Remove doubled /opds/opds/ path prefix in feed URLs
- Include ?token= auth param on all OPDS URLs
- Serve kepub links only for Kobo devices
- Fix URL construction for entries and acquisitions
2026-06-02 19:45:21 -04:00
john-okeefe 8e4412544d fix(devices): move device creation from CheckRegistration to ApproveDevice
Previously device creation happened in CheckRegistrationStatus (polling
endpoint), which was racy. Now the admin's ApproveDevice handler
creates the device record and stores auth token + device ID on the
registration entry. CheckRegistrationStatus just returns the pre-created
credentials.

Also adds approved/authToken/deviceID/syncEndpoints fields to
PendingRegistration struct.
2026-06-02 19:45:14 -04:00
john-okeefe d764f820b2 fix(conflicts): use winner's source name instead of 'manual'
When resolving a conflict, the last_sync_source is now set to the
winner's actual source name (koreader, web, etc.) rather than always
'manual'. This prevents subsequent saves from re-triggering conflicts.

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

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

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

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

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

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

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

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

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

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

Fix (two parts):

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

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

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

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

Add two Make macros to handle this transparently:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add seriesDetailPage Alpine component for the detail page's
library switcher (simple navigation, no AJAX needed).
2026-05-08 20:50:59 -04:00
john-okeefe a57694b738 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 3bcae0abf5 chore: regenerate templ Go files (path reference update) 2026-05-08 20:27:50 -04:00
john-okeefe cce7ad4907 test(series): add unit and integration tests for series feature
Unit tests:
- series_service_test.go: test continueSeriesRowToMediaItems conversion
  with valid fields, null fields, and comprehensive field mapping
- series_test.go: test handler initialization and textToString helper

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

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

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

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

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

Add SeriesCardData type to templates/types.go.

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

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

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

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

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

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

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

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

- analytics.go: date keys, lastSync, lastRead timestamps
- progress.go: lastUpdated timestamp in GetAllProgress
- book_detail.templ: LastReadAt, DatePublished
- book_detail_modals.templ: progress sync timestamps, LastReadAt
- devices.templ: LastSync, LastSeen
- conflicts.templ: CreatedAt
- admin_users.templ: user CreatedAt date
2026-04-27 21:31:19 -04:00
john-okeefe f589bedad5 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 27e9a654bf 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 caf50ade31 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 dd1ddff08d 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 1863fb3380 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 bc58103c62 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 37f84dd3ea 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 2736409a79 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 94102af4d7 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 cb214b16b2 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 4fe36ba0a5 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 283b2f2ed7 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 f699899408 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 e87f481988 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 d68f72f21b 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 d400377474 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 7cd88b6107 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 d38804e910 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 192c978a38 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 44ec2f496a 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 d3ddecb840 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 da1732285f 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 0269403a5d 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 a4962a87b2 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 35c8ffe33e 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 70d8ffd528 build: regenerate CSS after dashboard changes 2026-04-23 17:01:30 -04:00
john-okeefe 3a3fa8763e 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 4119a5e38e 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 86f44230c2 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 3c3c4e8bf5 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 133ca1fdaa 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 c7f0eb406a 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 2fdf894216 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 17f2dc3120 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 a06c85e72a 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 e389df92c3 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 ad27902790 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 855ef161d8 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 a6700f73e0 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 8baecad379 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 9ccff320a1 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 d8d6334052 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 5b2d105609 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 18811cea1b 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 3e73a582ba 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 8aeae33167 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 3cecb04e8d 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 6820208a36 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 77cbbf600b 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 4e326dfc86 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
222 changed files with 22919 additions and 5416 deletions
+4 -1
View File
@@ -22,4 +22,7 @@ logs/
node_modules/
# Environment
.env
.env
# Media uploads (bind-mounted at runtime)
uploads/
+18 -1
View File
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
# Generate with: openssl rand -hex 16
DBPASS=your-secure-database-password-here
# Networking: change a port if it conflicts on your host
# Postgres port, host + container (e.g. another local DB already uses 5432)
# DB_PORT=15432
# App web port, host + container
# SERVER_PORT=8765
# Deployment
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
# BASE_URL=https://bookhoard.example.com
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
# COOKIE_SECURE=true
# Pin or rollback a specific published image version (defaults to "latest")
# IMAGE_TAG=1.0.0
# Optional: Override Defaults (defaults are set in docker-compose.yml)
# Test Mode: WARNING - Only set to true for integration testing
# TEST_MODE=true
@@ -19,4 +34,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
+43
View File
@@ -0,0 +1,43 @@
name: Release
# Builds and publishes the Bookhoard container image to the Gitea container registry.
# Triggered ONLY by a version tag push (pushing to main does nothing), so work-in-progress
# commits never ship. Each release publishes two image tags: the version and "latest".
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.linuxhg.com
username: ${{ gitea.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
# pin or roll back by setting IMAGE_TAG in .env.
tags: |
git.linuxhg.com/bookhoard/bookhoard:${{ gitea.ref_name }}
git.linuxhg.com/bookhoard/bookhoard:latest
+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
+9 -6
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,14 +7,16 @@ 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 ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
npm install
# Copy Go mod files (cached unless go.mod changes)
COPY go.mod go.sum ./
@@ -34,11 +36,12 @@ RUN npm run build:ts
# Build Go binary (cached unless Go files or generated code changes)
RUN --mount=type=cache,target=/root/go/pkg/mod \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
# 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
+32 -24
View File
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
export
endif
# Auto-detect container runtime: prefer docker, fall back to podman
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
# Dev compose stack: base prod file merged with the dev override (local build + tests).
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
# Default target
help:
@echo "Available targets:"
@@ -44,23 +52,23 @@ test:
# Run integration tests in containers (matches production environment)
test-integration:
@echo "Building test containers..."
podman compose --profile tests build
$(COMPOSE) --profile tests build
@echo "Starting application containers..."
podman compose up -d db app
$(COMPOSE) up -d db app
@echo "Waiting for services to be healthy..."
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
echo " Database not ready yet..."; \
sleep 2; \
done; \
echo " ✓ Database is ready"
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
echo " Application not ready yet..."; \
sleep 2; \
done; \
echo " ✓ Application is ready"
@echo ""
@echo "Running integration tests in container..."
podman compose --profile tests run --rm tests
$(COMPOSE) --profile tests run --rm tests
@echo ""
@echo "✅ Integration tests completed!"
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
@@ -71,76 +79,76 @@ test-all: test test-integration
# Rebuild app container only (preserve DB, with cache)
rebuild-app:
@echo "Rebuilding app container (database stays running)..."
podman compose up --build --force-recreate -d app
$(COMPOSE) up --build --force-recreate -d app
@echo "✓ App container rebuilt and restarted"
# Rebuild app container only (preserve DB, no cache)
rebuild-app-force:
@echo "Force rebuilding app container (database stays running, no cache)..."
podman compose build --no-cache app
podman compose up --force-recreate -d app
$(COMPOSE) build --no-cache app
$(COMPOSE) up --force-recreate -d app
@echo "✓ App container rebuilt and restarted"
# Rebuild all containers (preserve DB, with cache)
rebuild:
@echo "Rebuilding all containers (database preserved)..."
podman compose up --build --force-recreate -d
$(COMPOSE) up --build --force-recreate -d
@echo "✓ All containers rebuilt and restarted"
# Rebuild all containers (preserve DB, no cache)
rebuild-force:
@echo "Force rebuilding all containers (database preserved, no cache)..."
podman compose build --no-cache
podman compose up --force-recreate -d
$(COMPOSE) build --no-cache
$(COMPOSE) up --force-recreate -d
@echo "✓ All containers rebuilt and restarted"
# Rebuild all containers (remove DB, no cache)
rebuild-force-db:
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
podman compose down -v
podman compose build --no-cache
podman compose up --force-recreate -d
$(COMPOSE) down -v
$(COMPOSE) build --no-cache
$(COMPOSE) up --force-recreate -d
@echo "✓ All containers rebuilt and restarted"
# Stop and remove containers
clean:
podman compose down -v
$(COMPOSE) down -v
# Quick start (if already built)
up:
podman compose up -d
$(COMPOSE) up -d
# Stop all containers (alias for clean)
down:
podman compose down
$(COMPOSE) down
# Restart app container (preserves database)
restart:
@echo "Restarting app container (database stays running)..."
podman compose restart app
$(COMPOSE) restart app
@echo "✓ App container restarted"
# Show container status
ps:
podman compose ps
$(COMPOSE) ps
# Show container logs
logs:
podman compose logs -f
$(COMPOSE) logs -f
# Start containers with test mode enabled for manual testing
test-env-up:
@echo "Starting containers with test mode enabled..."
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
@echo "Waiting for services to be ready..."
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
@echo "✓ Test environment is ready!"
@echo "Application available at http://localhost:8765"
# Stop test environment
test-env-down:
podman compose down -v
$(COMPOSE) down -v
# Verify project guidelines compliance
verify-guidelines:
-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
+5 -3
View File
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
```bash
# 1. Clone the repository
git clone https://github.com/yourusername/bookhoard.git
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
cd bookhoard
# 2. Set up environment
@@ -35,8 +35,10 @@ cp .env.example .env
# DBPASS: openssl rand -hex 16
# Edit .env with your generated values
# 3. Start the server
podman-compose up --build -d # or: docker-compose up --build -d
# 3. Pull images and start the server
docker compose pull
docker compose up -d
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
# 4. Open your browser
open http://localhost:8765
+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
+20 -3
View File
@@ -62,32 +62,45 @@ func main() {
// Create WebSocket connection manager
connManager := sync.NewConnectionManager()
// Create sync queue processor
progressService := sync.NewProgressService(queries, connManager)
annotationService := sync.NewAnnotationService(queries, connManager)
tombstonePurgerCancel := annotationService.StartTombstonePurger()
defer tombstonePurgerCancel()
queueProcessor := sync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService)
queueProcessor.SetAnnotationService(annotationService)
// 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)
koreaderHandler.SetAnnotationService(annotationService)
koreaderHandler.SetLibraryService(libraryService)
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)
mediaHandler.SetAnnotationService(annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker)
@@ -147,15 +160,19 @@ func main() {
FiltersHandler: filtersHandler,
DashboardHandler: dashboardHandler,
DashboardService: dashboardService,
SeriesHandler: seriesHandler,
OPDSHandler: opdsHandler,
Worker: worker,
SystemSettingsHandler: systemSettingsHandler,
SidecarHandler: sidecarHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
JobsHandler: jobsHandler,
LoginTracker: loginAttemptTracker,
LibraryService: libraryService,
}
// Register all routes and get ebook handler
+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")
}
+75 -37
View File
@@ -13,6 +13,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
@@ -76,19 +77,21 @@ type DeviceTestData struct {
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
type TestServerSetup struct {
Server *httptest.Server
DB *database.Queries
DBPool *pgxpool.Pool
Config *config.Config
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
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
AnnotationService *wsync.AnnotationService
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 +287,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 +319,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 +455,23 @@ func setupTestServer(t *testing.T) *TestServerSetup {
connManager := wsync.NewConnectionManager()
cleanupCancel := connManager.StartCleanupTask()
// Create sync queue processor with cancellable context
progressService := wsync.NewProgressService(queries, connManager)
annotationService := wsync.NewAnnotationService(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)
koreaderHandler.SetAnnotationService(annotationService)
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 +482,10 @@ 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)
mediaHandler.SetAnnotationService(annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS
@@ -514,14 +530,18 @@ 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,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
}
@@ -543,11 +563,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 +625,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 +673,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 +726,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 +749,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 +770,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 +807,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)
+55 -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',
@@ -242,6 +245,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
character_offset BIGINT,
epubcfi TEXT,
context_text TEXT,
chapter INTEGER,
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
viewport_x FLOAT DEFAULT 0,
@@ -457,6 +461,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);
@@ -933,6 +938,7 @@ BEGIN
percentage = (book_record->>'percentage')::FLOAT,
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
chapter_progress = (book_record->>'percentage')::FLOAT,
last_sync_device = 'koreader',
@@ -951,6 +957,7 @@ BEGIN
percentage,
character_offset,
epubcfi,
context_text,
chapter,
chapter_progress,
last_sync_device,
@@ -967,6 +974,7 @@ BEGIN
(book_record->>'percentage')::FLOAT,
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
(book_record->>'percentage')::FLOAT,
'koreader',
@@ -1304,3 +1312,48 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
-- ============================================
-- ANNOTATION SYNC MIGRATIONS
-- Adds dedup_key, LWW timestamps, soft-delete,
-- and device_sync_data to annotation tables.
-- ============================================
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
ON media_highlights (user_id, media_item_id, dedup_key)
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
ON media_notes (user_id, media_item_id, dedup_key)
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
ON media_bookmarks (user_id, media_item_id, dedup_key)
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
+57
View File
@@ -0,0 +1,57 @@
# Development override — merged on top of docker-compose.yml (the base/prod file).
# Activated by all `make` targets via:
# COMPOSE = <runtime> compose -f docker-compose.yml -f docker-compose.dev.yml
#
# What this adds over prod:
# - Local image BUILDING (prod pulls a prebuilt image from the registry)
# - The integration-tests service (dev only, gated behind the "tests" profile)
# Everything else (env vars, volumes, ports, healthchecks) is inherited from the base file.
services:
# Build the app image locally instead of pulling from the registry
app:
build:
context: .
dockerfile: ./Dockerfile
# Integration Tests - runs against containerized app and db (dev only)
tests:
build:
context: .
dockerfile: ./Dockerfile
target: test-runner
container_name: bookhoard_tests
environment:
# Database Configuration
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: bookhoard
COOKIE_SECURE: false
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: ${SERVER_PORT:-8765}
# Test Configuration
TEST_MODE: "true"
RATE_LIMIT_ENABLED: "false"
REQUESTS_PER_MINUTE: 1000
# Conversion Service Configuration
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
# Test upload path (inside container)
TEST_UPLOAD_PATH: /app/uploads
depends_on:
db:
condition: service_healthy
app:
condition: service_healthy
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
profiles:
- tests
+19 -57
View File
@@ -1,5 +1,3 @@
version: "3.8"
services:
# PostgreSQL Database
db:
@@ -9,44 +7,48 @@ services:
POSTGRES_DB: bookhoard
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DBPASS}
COOKIE_SECURE: false # make true in production with HTTPS
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
PGPORT: ${DB_PORT:-5432}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
# Make other volumes as needed
- ./uploads:/app/uploads
ports:
- "5432:5432"
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
env_file:
- .env
# Bookhoard Application
# In production this image is pulled from the Gitea container registry.
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
app:
build:
context: .
dockerfile: ./Dockerfile
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
container_name: bookhoard
environment:
# Database Configuration
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: bookhoard
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: 8765
SERVER_PORT: ${SERVER_PORT:-8765}
# IMPORTANT: Device sync requires full URL with protocol
# Local: http://localhost:8765
# Local network: http://192.168.1.X:8765
# Domain: https://bookhoard.example.com
BASE_URL: http://localhost:${SERVER_PORT}
BASE_URL: ${BASE_URL:-http://localhost:8765}
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
COOKIE_SECURE: ${COOKIE_SECURE:-false}
# Rate Limiting Configuration
TEST_MODE: ${TEST_MODE:-false}
@@ -57,8 +59,11 @@ 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"
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
depends_on:
db:
condition: service_healthy
@@ -66,55 +71,12 @@ services:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
interval: 10s
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# Integration Tests - runs against containerized app and db
tests:
build:
context: .
dockerfile: ./Dockerfile
target: test-runner
container_name: bookhoard_tests
environment:
# Database Configuration
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: bookhoard
COOKIE_SECURE: false
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: 8765
# Test Configuration
TEST_MODE: "true"
RATE_LIMIT_ENABLED: "false"
REQUESTS_PER_MINUTE: 1000
# Conversion Service Configuration
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
# Test upload path (inside container)
TEST_UPLOAD_PATH: /app/uploads
depends_on:
db:
condition: service_healthy
app:
condition: service_healthy
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
profiles:
- tests
# Named Volumes
volumes:
postgres_data:
+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
}
}
}
+54 -31
View File
@@ -155,40 +155,55 @@ type LibraryVisibility struct {
}
type MediaBookmarks struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
Title string `db:"title" json:"title"`
Position pgtype.Text `db:"position" json:"position"`
Notes pgtype.Text `db:"notes" json:"notes"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
Title string `db:"title" json:"title"`
Position pgtype.Text `db:"position" json:"position"`
Notes pgtype.Text `db:"notes" json:"notes"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
}
type MediaHighlights struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SelectionText string `db:"selection_text" json:"selection_text"`
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
Color pgtype.Text `db:"color" json:"color"`
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SelectionText string `db:"selection_text" json:"selection_text"`
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
Color pgtype.Text `db:"color" json:"color"`
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
NoteText pgtype.Text `db:"note_text" json:"note_text"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
}
type MediaItemFormats struct {
@@ -239,6 +254,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"`
@@ -303,6 +319,11 @@ type MediaNotes struct {
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
}
type MediaRatings struct {
@@ -379,6 +400,7 @@ type ReadingProgress struct {
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
ContextText pgtype.Text `db:"context_text" json:"context_text"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
@@ -507,5 +529,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"`
}
+53
View File
@@ -23,15 +23,18 @@ 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
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
CountAdmins(ctx context.Context) (int64, error)
// Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
// COLLECTIONS QUERIES
// Create collection
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
@@ -53,8 +56,10 @@ type Querier interface {
// Libraries queries
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
// Media Highlights queries
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
// Media Items queries
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
// MEDIA ITEM FORMATS QUERIES
@@ -62,6 +67,7 @@ type Querier interface {
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
// Media Notes queries
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
// OPDS TOKENS QUERIES
// Create OPDS token
@@ -124,10 +130,15 @@ type Querier interface {
DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
// ============================================
// ANNOTATION SERVE QUERIES
// ============================================
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
// Get all system config
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 +152,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 +178,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 +193,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)
@@ -188,8 +205,17 @@ type Querier interface {
// LIBRARY WITH TYPE INFO QUERIES
// ============================================================================
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error)
// ============================================
// ANNOTATION SYNC QUERIES (bookmarks)
// ============================================
GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error)
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
// ============================================
// ANNOTATION SYNC QUERIES (highlights)
// ============================================
GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error)
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
@@ -212,6 +238,10 @@ type Querier interface {
// Get media item formats
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
// ============================================
// ANNOTATION SYNC QUERIES (notes)
// ============================================
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
@@ -235,6 +265,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 +278,8 @@ 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)
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
// Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
// Get unlinked book by ContentId
@@ -269,13 +303,16 @@ type Querier interface {
// Get user reading history for analytics
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
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)
@@ -289,8 +326,13 @@ type Querier interface {
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) 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 +358,13 @@ 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
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
@@ -339,7 +388,9 @@ type Querier interface {
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
// Update media item format
@@ -357,6 +408,7 @@ type Querier interface {
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
@@ -370,6 +422,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
+413 -38
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,36 @@ 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: CountAdmins :one
SELECT COUNT(*) FROM users WHERE role = 'admin';
-- 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;
@@ -644,7 +696,7 @@ RETURNING *;
SELECT * FROM media_notes WHERE id = $1;
-- name: GetMediaNotes :many
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
-- name: UpdateMediaNote :one
UPDATE media_notes SET
@@ -667,7 +719,7 @@ RETURNING *;
SELECT * FROM media_highlights WHERE id = $1;
-- name: GetMediaHighlights :many
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
-- name: UpdateMediaHighlight :one
UPDATE media_highlights SET
@@ -683,6 +735,251 @@ RETURNING *;
-- name: DeleteMediaHighlight :exec
DELETE FROM media_highlights WHERE id = $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (highlights)
-- ============================================
-- name: GetMediaHighlightByDedupKey :one
SELECT * FROM media_highlights
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaHighlightFull :one
INSERT INTO media_highlights (
media_item_id, user_id, selection_text,
start_position, end_position, color, note_text,
percentage_start, percentage_end,
epubcfi_start, epubcfi_end,
chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
) RETURNING *;
-- name: UpdateMediaHighlightForSync :one
UPDATE media_highlights SET
selection_text = $2,
start_position = $3,
end_position = $4,
color = $5,
note_text = $6,
percentage_start = $7,
percentage_end = $8,
epubcfi_start = $9,
epubcfi_end = $10,
chapter_reference = $11,
last_modified_at = $12,
last_modified_source = $13,
device_sync_data = $14,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaHighlightByDedupKey :exec
UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaHighlightByID :exec
UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredHighlightTombstones :exec
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (notes)
-- ============================================
-- name: GetMediaNoteByDedupKey :one
SELECT * FROM media_notes
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaNoteFull :one
INSERT INTO media_notes (
media_item_id, user_id, content, position,
percentage_location, character_start, character_end,
epubcfi_location, chapter_reference, paragraph_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING *;
-- name: UpdateMediaNoteForSync :one
UPDATE media_notes SET
content = $2,
position = $3,
percentage_location = $4,
character_start = $5,
character_end = $6,
epubcfi_location = $7,
chapter_reference = $8,
paragraph_reference = $9,
last_modified_at = $10,
last_modified_source = $11,
device_sync_data = $12,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaNoteByDedupKey :exec
UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaNoteByID :exec
UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredNoteTombstones :exec
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (bookmarks)
-- ============================================
-- name: GetMediaBookmarkByDedupKey :one
SELECT * FROM media_bookmarks
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaBookmarkFull :one
INSERT INTO media_bookmarks (
media_item_id, user_id, page_number, chapter_number,
cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) RETURNING *;
-- name: UpdateMediaBookmarkForSync :one
UPDATE media_bookmarks SET
page_number = $2,
chapter_number = $3,
cfi_position = $4,
title = $5,
position = $6,
notes = $7,
percentage_location = $8,
epubcfi_location = $9,
chapter_reference = $10,
last_modified_at = $11,
last_modified_source = $12,
device_sync_data = $13,
created_at = created_at
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaBookmarkByDedupKey :exec
UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaBookmarkByID :exec
UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredBookmarkTombstones :exec
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SERVE QUERIES
-- ============================================
-- name: GetActiveAnnotationsForBook :many
SELECT
mh.id,
mh.selection_text,
mh.start_position,
mh.end_position,
mh.color,
mh.created_at,
mh.updated_at,
'highlight' as annotation_type,
mh.percentage_start,
mh.percentage_end,
mh.epubcfi_start,
mh.epubcfi_end,
mh.note_text,
mh.dedup_key,
mh.last_modified_at,
mh.last_modified_source
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
UNION ALL
SELECT
mn.id,
mn.content,
mn.position,
NULL as end_position,
NULL as color,
mn.created_at,
mn.updated_at,
'note' as annotation_type,
mn.percentage_location as percentage_start,
NULL as percentage_end,
mn.epubcfi_location as epubcfi_start,
NULL as epubcfi_end,
NULL as note_text,
mn.dedup_key,
mn.last_modified_at,
mn.last_modified_source
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
ORDER BY created_at DESC;
-- name: GetTombstonedAnnotationsForBook :many
SELECT
mh.id,
mh.dedup_key,
'highlight' as annotation_type,
mh.device_sync_data,
mh.deleted_at
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
UNION ALL
SELECT
mn.id,
mn.dedup_key,
'note' as annotation_type,
mn.device_sync_data,
mn.deleted_at
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
UNION ALL
SELECT
mb.id,
mb.dedup_key,
'bookmark' as annotation_type,
mb.device_sync_data,
mb.deleted_at
FROM media_bookmarks mb
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
ORDER BY deleted_at DESC;
-- Refresh Tokens queries
-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (user_id, token, expires_at)
@@ -744,6 +1041,7 @@ SELECT
rp.percentage,
rp.character_offset,
rp.epubcfi,
rp.context_text,
rp.chapter,
rp.chapter_progress,
rp.viewport_x,
@@ -776,6 +1074,7 @@ INSERT INTO reading_progress (
percentage,
character_offset,
epubcfi,
context_text,
chapter,
chapter_progress,
viewport_x,
@@ -793,13 +1092,14 @@ INSERT INTO reading_progress (
last_read_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
percentage = EXCLUDED.percentage,
character_offset = EXCLUDED.character_offset,
epubcfi = EXCLUDED.epubcfi,
context_text = EXCLUDED.context_text,
chapter = EXCLUDED.chapter,
chapter_progress = EXCLUDED.chapter_progress,
viewport_x = EXCLUDED.viewport_x,
@@ -1071,6 +1371,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: CreateAutoResolvedSyncConflict :one
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
RETURNING *;
-- name: GetSyncConflict :one
SELECT * FROM sync_conflicts WHERE id = $1;
@@ -1099,19 +1404,28 @@ 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;
-- name: HasRecentConflictResolution :one
SELECT EXISTS(
SELECT 1 FROM sync_conflicts
WHERE media_item_id = $1
AND user_id = $2
AND resolution_status != 'unresolved'
AND resolved_at > NOW() - INTERVAL '10 minutes'
);
-- ============================================
-- KOREADER SYNC PROTOCOL
@@ -1161,7 +1475,7 @@ SELECT
mh.epubcfi_start,
mh.epubcfi_end
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
UNION ALL
SELECT
mn.id,
@@ -1177,7 +1491,7 @@ SELECT
mn.epubcfi_location as epubcfi_start,
NULL as epubcfi_end
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
ORDER BY created_at DESC;
-- name: UpdateDeviceSyncTimestamp :one
@@ -1216,7 +1530,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 +2095,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 +2159,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
@@ -1995,9 +2362,12 @@ RETURNING *;
-- name: GetMediaBookmarks :many
SELECT * FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
ORDER BY created_at DESC;
-- name: GetMediaBookmark :one
SELECT * FROM media_bookmarks WHERE id = $1;
-- name: CreateMediaBookmark :one
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -2093,3 +2463,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")
}
}
+69 -22
View File
@@ -6,7 +6,9 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"bookhoard/internal/setupstatus"
"context"
"errors"
"fmt"
"net/http"
"os"
@@ -81,20 +83,22 @@ type UserProfile struct {
}
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"`
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
}
type AdminUpdateUserRequest 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"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
}
// Register handles POST /api/auth/register
@@ -187,7 +191,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
}
var userRole string
if len(users) == 0 {
if !adminExists {
userRole = "admin"
} else {
userRole = req.Role
@@ -229,6 +233,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// A new user may have changed the admin count (e.g. first user becomes
// admin), so refresh the setup-status cache.
setupstatus.Invalidate()
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
@@ -261,7 +269,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 +415,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 +503,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
@@ -545,6 +553,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Role changes can affect the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
}
// Update username (if provided)
@@ -563,6 +574,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,16 +786,16 @@ 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
}
type PasswordRequest struct {
CurrentPassword string `json:"current_password,omitempty"`
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" validate:"required"`
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
}
var req PasswordRequest
@@ -839,7 +866,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 +911,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>`)
}
@@ -899,6 +942,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Deletion may have changed the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
// Create success message based on context
var message string
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
@@ -940,7 +986,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 +1042,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()})
+174 -20
View File
@@ -5,6 +5,7 @@ import (
wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"errors"
"net/http"
"time"
@@ -27,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
}
type ConflictResolutionRequest struct {
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
Winner string `json:"winner" validate:"required"`
ManualData map[string]interface{} `json:"manual_data"`
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
Reason string `json:"reason"`
@@ -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")
@@ -224,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if conflict.ResolutionStatus.String != "unresolved" {
if conflict.ResolutionStatus.String == "user_resolved" {
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
}
@@ -234,8 +235,10 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
}
winnerData := map[string]interface{}{}
winnerSource := req.Winner
if req.Winner == "manual" {
winnerData = req.ManualData
winnerSource = "manual"
} else {
source, ok := conflictData[req.Winner]
if !ok {
@@ -250,11 +253,17 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
}
if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
appliedTo["progress"] = true
}
}
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
appliedTo["annotations"] = true
}
}
resolutionData := map[string]interface{}{
"winner": req.Winner,
"applied_to": appliedTo,
@@ -285,14 +294,14 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
return c.JSON(http.StatusOK, response)
}
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
@@ -316,8 +325,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}
@@ -337,7 +350,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
@@ -349,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
return err
}
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
ctx := context.Background()
dedupKey, _ := winnerData["dedup_key"].(string)
if dedupKey == "" {
return errors.New("missing dedup_key in winner data")
}
switch conflictType {
case "annotation_highlight":
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
case "annotation_bookmark":
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
case "annotation_note":
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
default:
return errors.New("unknown annotation conflict type")
}
}
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaHighlightForSyncParams{
ID: existing.ID,
SelectionText: existing.SelectionText,
StartPosition: existing.StartPosition,
EndPosition: existing.EndPosition,
Color: existing.Color,
NoteText: existing.NoteText,
PercentageStart: existing.PercentageStart,
PercentageEnd: existing.PercentageEnd,
EpubcfiStart: existing.EpubcfiStart,
EpubcfiEnd: existing.EpubcfiEnd,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["selection_text"].(string); ok {
params.SelectionText = v
}
if v, ok := data["color"].(string); ok {
params.Color = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["note_text"].(string); ok {
params.NoteText = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["start_position"].(string); ok {
params.StartPosition = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["end_position"].(string); ok {
params.EndPosition = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
return err
}
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: existing.PageNumber,
ChapterNumber: existing.ChapterNumber,
CfiPosition: existing.CfiPosition,
Title: existing.Title,
Position: existing.Position,
Notes: existing.Notes,
PercentageLocation: existing.PercentageLocation,
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["title"].(string); ok {
params.Title = v
}
if v, ok := data["notes"].(string); ok {
params.Notes = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
return err
}
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaNoteForSyncParams{
ID: existing.ID,
Content: existing.Content,
Position: existing.Position,
PercentageLocation: existing.PercentageLocation,
CharacterStart: existing.CharacterStart,
CharacterEnd: existing.CharacterEnd,
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
ParagraphReference: existing.ParagraphReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["content"].(string); ok {
params.Content = v
}
if v, ok := data["position"].(string); ok {
params.Position = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
return err
}
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
if err != nil {
@@ -376,7 +526,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 +546,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")
}
@@ -546,7 +696,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue
}
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
@@ -630,14 +780,14 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
return highestSource, highestData
}
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && err != pgx.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
@@ -661,8 +811,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}
@@ -681,8 +835,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
+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
+76 -68
View File
@@ -100,6 +100,10 @@ type PendingRegistration struct {
UserID uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
Approved bool
AuthToken string
DeviceID [16]byte
SyncEndpoints map[string]string
}
var pendingRegistrations = make(map[string]*PendingRegistration)
@@ -173,60 +177,21 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
}
if registration.UserID == (uuid.UUID{}) {
if registration.Approved {
delete(pendingRegistrations, req.RegistrationID)
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "pending",
Message: "awaiting user approval",
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
Status: "approved",
AuthToken: registration.AuthToken,
DeviceID: registration.DeviceID,
SyncEndpoints: registration.SyncEndpoints,
})
}
authToken, err := generateDeviceToken()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
}
userUUID := registration.UserID
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
autoSync := pgtype.Bool{Bool: true, Valid: true}
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequencyMinutes: syncFreq,
DeviceMetadata: []byte("{}"),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
}
delete(pendingRegistrations, req.RegistrationID)
syncEndpoints := map[string]string{}
switch registration.DeviceType {
case "koreader":
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
case "kobo":
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
}
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "approved",
AuthToken: authToken,
DeviceID: device.ID.Bytes,
SyncEndpoints: syncEndpoints,
Status: "pending",
Message: "awaiting user approval",
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
})
}
@@ -257,8 +222,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 +266,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 +318,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 +412,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 +531,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,
@@ -597,14 +562,63 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
}
if registration.Approved {
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device already approved",
"device_name": registration.DeviceName,
"device_type": registration.DeviceType,
"approved": true,
})
}
authToken, err := generateDeviceToken()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
}
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
autoSync := pgtype.Bool{Bool: true, Valid: true}
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequencyMinutes: syncFreq,
DeviceMetadata: []byte("{}"),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
}
syncEndpoints := map[string]string{}
switch registration.DeviceType {
case "koreader":
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
case "kobo":
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
}
registration.UserID = userUUID
registration.Approved = true
registration.AuthToken = authToken
registration.DeviceID = device.ID.Bytes
registration.SyncEndpoints = syncEndpoints
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device approved successfully",
"device_name": registration.DeviceName,
"device_type": registration.DeviceType,
"registration_id": registrationID,
"approved": true, // Fixed: Add confirmation field for test compatibility
"approved": true,
})
}
@@ -624,15 +638,9 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
}
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return nil, err
}
registrations := []map[string]interface{}{}
for _, reg := range pendingRegistrations {
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
if reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{
"registration_id": reg.RegistrationID,
"device_name": reg.DeviceName,
@@ -640,7 +648,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
"device_identifier": reg.DeviceIdentifier,
"expires_at": reg.ExpiresAt,
"created_at": reg.CreatedAt,
"is_approved": reg.UserID != (uuid.UUID{}),
"is_approved": false,
})
}
}
+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),
}
+406 -148
View File
@@ -3,10 +3,9 @@ package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strings"
@@ -18,14 +17,29 @@ import (
)
type KoboHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver
}
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
}
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = 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 +47,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 +66,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 +189,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"`
@@ -243,9 +251,16 @@ type KoboInitResponse struct {
}
type KoboSyncStatus struct {
Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"`
Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"`
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
}
type KoboDeletedAnnotation struct {
ContentId string `json:"ContentId"`
BookmarkId string `json:"BookmarkId"`
Type string `json:"Type"`
}
type KoboServerSyncData struct {
@@ -303,12 +318,13 @@ 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
}
}
bookmarkCount := 0
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
UserID: pgUserID,
})
@@ -400,41 +416,58 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
markupsSynced := 0
bookmarksSynced := 0
unlinkedBooks := 0
processedBooks := make(map[pgtype.UUID]string)
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}
processedBooks[pgMediaUUID] = readingSync.ContentId
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},
})
// Kobo only sends a percentage. For fixed-layout & comic formats the page
// index is the canonical locator, so derive it from the known page count.
var currentPage, totalPages *int
if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil {
if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
total := int(mediaItem.PageCount.Int32)
page := wsync.PercentageToPage(percentage, total)
currentPage = &page
totalPages = &total
}
}
}
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
CurrentPage: currentPage,
TotalPages: totalPages,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
})
} 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",
},
)
}
}
@@ -447,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
switch bookmarkSync.BookmarkType {
case "annotation":
if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
bookmarksSynced++
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated,
})
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: bookmarkSync.BookmarkId,
EndPosition: bookmarkSync.BookmarkId,
Color: "#ffff00",
NoteText: bookmarkSync.BookmarkTitle,
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
bookmarksSynced++
}
}
case "bookmark":
if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmarkSync.BookmarkText,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
})
bookmarksSynced++
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated,
})
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Title: bookmarkSync.BookmarkText,
Position: bookmarkSync.BookmarkId,
ChapterNumber: int32(bookmarkSync.Chapter),
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmarkSync.BookmarkText,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
})
bookmarksSynced++
}
}
case "last-read-place":
if bookmarkSync.BookmarkId != "" {
@@ -480,15 +556,51 @@ 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
var convertedCFI *string
var contextText *string
if epubcfi != "" && h.libraryService != nil {
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
if mErr == nil {
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
}
}
}
if convertedCFI != nil {
epubcfi = *convertedCFI
}
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,
ContextText: contextText,
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)
}
@@ -510,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
BookmarksSynced: bookmarksSynced,
}
if h.annotationSvc != nil && len(processedBooks) > 0 {
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
for mediaItemID, contentId := range processedBooks {
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
DeletedAt: cutoff,
})
for _, ts := range tombstones {
var dd map[string]interface{}
if len(ts.DeviceSyncData) > 0 {
json.Unmarshal(ts.DeviceSyncData, &dd)
}
bookmarkID, _ := dd["bookmark_id"].(string)
if bookmarkID == "" {
continue
}
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
ContentId: contentId,
BookmarkId: bookmarkID,
Type: ts.AnnotationType,
})
}
}
}
// Include unlinked books count if any
if unlinkedBooks > 0 {
// For now, just log it. In production, this should trigger an alert
@@ -552,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
switch bookmarkSync.BookmarkType {
case "annotation":
if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
bookmarksSynced++
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated,
})
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: bookmarkSync.BookmarkId,
EndPosition: bookmarkSync.BookmarkId,
Color: "#ffff00",
NoteText: bookmarkSync.BookmarkTitle,
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
bookmarksSynced++
}
}
case "bookmark":
if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmarkSync.BookmarkText,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
})
bookmarksSynced++
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{
"bookmark_id": bookmarkSync.BookmarkId,
"date_created": bookmarkSync.DateCreated,
})
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Title: bookmarkSync.BookmarkText,
Position: bookmarkSync.BookmarkId,
ChapterNumber: int32(bookmarkSync.Chapter),
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmarkSync.BookmarkText,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
})
bookmarksSynced++
}
}
}
}
@@ -605,34 +785,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 +827,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 +847,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++
@@ -707,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
for _, bookmark := range syncData.Bookmarks {
if bookmark.BookmarkType == "bookmark" {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmark.BookmarkText,
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
})
bookmarksSent++
if h.annotationSvc != nil {
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Title: bookmark.BookmarkText,
Position: bookmark.BookmarkId,
Source: "kobo",
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSent++
}
} else {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmark.BookmarkText,
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
})
bookmarksSent++
}
} else if bookmark.BookmarkType == "annotation" {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmark.BookmarkText,
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
highlightsSent++
if h.annotationSvc != nil {
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmark.BookmarkText,
StartPosition: bookmark.BookmarkId,
EndPosition: bookmark.BookmarkId,
Color: "#ffff00",
Source: "kobo",
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSent++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmark.BookmarkText,
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
highlightsSent++
}
}
}
for _, highlight := range syncData.Highlights {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: highlight.BookmarkText,
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
highlightsSent++
if h.annotationSvc != nil {
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: highlight.BookmarkText,
StartPosition: highlight.BookmarkId,
EndPosition: highlight.BookmarkId,
Color: "#ffff00",
Source: "kobo",
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSent++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: highlight.BookmarkText,
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
highlightsSent++
}
}
}
@@ -753,3 +971,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
HighlightsSent: highlightsSent,
})
}
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
return nil, nil
}
if epubPath == "" {
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
return nil, nil
}
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
FormatType: "kepub",
})
if err != nil || !kepubFormat.FilePath.Valid {
return nil, nil
}
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
if err != nil {
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
return nil, nil
}
var cfi *string
if result.CFI != "" {
cfi = &result.CFI
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
}
var ctx *string
if result.ExtractedContext != "" {
ctx = &result.ExtractedContext
}
return cfi, ctx
}
+557 -201
View File
@@ -3,27 +3,64 @@ package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type KOReaderHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor
db *database.Queries
connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor
progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver
}
type LibraryPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
}
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
}
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) {
if pos0 == "" || h.libraryService == nil {
return "", ""
}
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return "", ""
}
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if err != nil || epubPath == "" {
return "", ""
}
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
return startLoc.CFI, endLoc.CFI
}
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = svc
}
type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"`
@@ -43,11 +80,12 @@ type KOReaderBookProgress struct {
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
ContextText *string `json:"context_text,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
}
type KOReaderDeviceInfo struct {
@@ -96,11 +134,18 @@ type KOReaderNote struct {
}
type KOReaderSyncResponse struct {
SyncStatus string `json:"sync_status"`
BooksSynced int `json:"books_synced"`
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
Timestamp string `json:"timestamp"`
DeviceUpdated bool `json:"device_updated"`
SyncStatus string `json:"sync_status"`
BooksSynced int `json:"books_synced"`
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
Timestamp string `json:"timestamp"`
DeviceUpdated bool `json:"device_updated"`
}
type KOReaderBookSyncResult struct {
SHA256 string `json:"sha256"`
BookUUID string `json:"book_uuid"`
Synced bool `json:"synced"`
}
type KOReaderConflict struct {
@@ -121,19 +166,22 @@ type KOReaderMetadata struct {
}
type KOReaderProgressData struct {
Percentage float64 `json:"percentage"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
Chapter *int `json:"chapter,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
Percentage float64 `json:"percentage"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
Chapter *int `json:"chapter,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
}
type KOReaderAnnotations struct {
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
}
type KOReaderLibraryResponse struct {
@@ -184,17 +232,28 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
booksSynced := 0
conflicts := []KOReaderConflict{}
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue
}
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
if err == nil {
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
synced := err == nil
if synced {
booksSynced++
}
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
}
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
@@ -208,6 +267,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "accepted",
BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true,
@@ -217,6 +277,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusOK, KOReaderSyncResponse{
SyncStatus: "completed",
BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true,
@@ -343,17 +404,29 @@ func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
booksEnqueued := 0
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue
}
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
if err == nil {
synced := err == nil
if synced {
booksEnqueued++
}
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
}
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
@@ -363,11 +436,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
})
}
return c.JSON(http.StatusAccepted, map[string]interface{}{
"sync_status": "checkpoint_enqueued",
"books_enqueued": booksEnqueued,
"message": "Sync will be processed in the background",
"timestamp": time.Now().Format(time.RFC3339),
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "checkpoint_enqueued",
BooksSynced: booksEnqueued,
BookResults: bookResults,
Timestamp: time.Now().Format(time.RFC3339),
})
}
@@ -382,6 +455,7 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
UserID: userID,
Percentage: book.Percentage,
Epubcfi: book.Epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter,
Character: book.Character,
Page: book.Page,
@@ -393,68 +467,200 @@ 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 {
ctx := c.Request().Context()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && err != pgx.ErrNoRows {
return err
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
if h.annotationSvc == nil {
return
}
hasExistingProgress := err != pgx.ErrNoRows
conflictDetected := false
for _, hl := range book.Highlights {
startPos := hl.Pos0
endPos := hl.Pos1
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
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
pctStart := 0.0
if hl.Percentage != nil {
pctStart = *hl.Percentage
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": hl.Datetime,
"pos0": hl.Pos0,
"pos1": hl.Pos1,
"page": hl.Page,
})
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
SelectionText: hl.Text,
StartPosition: startPos,
EndPosition: endPos,
Color: hl.Color,
NoteText: hl.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
})
}
for _, note := range book.Notes {
startPos := note.Pos0
endPos := note.Pos1
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
pctStart := 0.0
if note.Percentage != nil {
pctStart = *note.Percentage
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime,
"pos0": note.Pos0,
"pos1": note.Pos1,
"page": note.Page,
})
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
SelectionText: note.Text,
StartPosition: startPos,
EndPosition: endPos,
NoteText: note.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
})
}
for _, bookmark := range book.Bookmarks {
position := ""
if bookmark.Pos0 != "" {
position = bookmark.Pos0
} else if bookmark.Page > 0 {
position = fmt.Sprintf("page:%d", bookmark.Page)
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
"page": bookmark.Page,
})
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
MediaItemID: mediaItemID,
UserID: userID,
Title: bookmark.Text,
Position: position,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
DeviceSyncData: deviceData,
})
}
}
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
ctx := c.Request().Context()
deviceInfo := book.DeviceInfo
deviceModel := deviceInfo.DeviceModel
if deviceModel == "" {
deviceModel = "KOReader Device"
}
if h.progressSvc != nil {
epubcfi := book.Epubcfi
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
// comic archives) has no extractable text, so CRE→CFI conversion
// cannot succeed. The page index (page/total_pages) is the
// canonical locator. Keep the incoming xpointer for device-native
// restore; the web reader restores by page.
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
} else if h.libraryService == nil {
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
} else {
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if resolveErr != nil {
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
} else if epubPath == "" {
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
} else {
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
converter := wsync.NewCFIConverter(epubPath)
pct := 0.0
if book.Percentage >= 0 {
pct = book.Percentage
}
contextText := ""
if book.ContextText != nil {
contextText = *book.ContextText
}
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
if convErr != nil {
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
} else if result != nil {
if result.EPUBCFI != "" {
convertedCFI := result.EPUBCFI
epubcfi = &convertedCFI
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
} else if result.Href != "" {
convertedHref := result.Href
epubcfi = &convertedHref
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
} else {
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
}
}
}
}
}
saveReq := wsync.SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
Source: "koreader",
DeviceID: deviceID,
Percentage: &book.Percentage,
Epubcfi: epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter,
CharacterOffset: book.Character,
CurrentPage: book.Page,
TotalPages: book.TotalPages,
DeviceType: "koreader",
DeviceName: deviceModel,
Broadcast: true,
}
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
if err != nil {
return err
}
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
return nil
}
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 +670,44 @@ 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})
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
return err
return nil
}
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 {
@@ -597,26 +750,42 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
Percentage: progress.Percentage.Float64,
}
if progress.Epubcfi.Valid {
progressData.Epubcfi = &progress.Epubcfi.String
// CFI/xpointer are meaningless for image-based fixed-layout content; the
// page index is the canonical locator. Only return them for reflowable docs.
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
isFixed := formatGroup == wsync.FormatGroupFixedLayout ||
formatGroup == wsync.FormatGroupComicArchive
if !isFixed {
if progress.Epubcfi.Valid {
progressData.Epubcfi = &progress.Epubcfi.String
}
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
}
}
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{
annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
@@ -629,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
for _, ann := range annotations {
if ann.AnnotationType == "highlight" {
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
pos0 := ann.StartPosition.String
pos1 := ann.EndPosition.String
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
pos0 = converted
}
}
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
pos1 = converted
}
}
highlight := KOReaderHighlight{
Text: ann.SelectionText,
Pos0: ann.StartPosition.String,
Pos1: ann.EndPosition.String,
Pos0: pos0,
Pos1: pos1,
Color: ann.Color.String,
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
})
}
if ann.NoteText.Valid && ann.NoteText.String != "" {
highlight.Notes = ann.NoteText.String
}
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
} else if ann.AnnotationType == "note" {
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
Text: ann.SelectionText,
@@ -645,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
}
}
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
for _, bm := range bookmarks {
pos0 := bm.Position.String
if pos0 == "" && bm.CfiPosition.Valid {
pos0 = bm.CfiPosition.String
}
koreaderBookmark := KOReaderBookmark{
Text: bm.Title,
Pos0: pos0,
Pos1: pos0,
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
}
if bm.Notes.Valid && bm.Notes.String != "" {
koreaderBookmark.Notes = bm.Notes.String
}
if bm.ChapterNumber.Valid {
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
}
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
}
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
DeletedAt: cutoff,
})
for _, ts := range tombstones {
var dd map[string]interface{}
if len(ts.DeviceSyncData) > 0 {
json.Unmarshal(ts.DeviceSyncData, &dd)
}
if dd == nil {
dd = map[string]interface{}{}
}
dd["dedup_key"] = ts.DedupKey.String
if ts.AnnotationType == "highlight" {
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
} else if ts.AnnotationType == "bookmark" {
annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd)
}
}
lastSync := "never"
if progress.LastSyncTimestamp.Valid {
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
@@ -662,6 +893,55 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
return c.JSON(http.StatusOK, metadata)
}
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
if h.libraryService == nil {
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
return
}
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
return
}
if epubPath == "" {
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
return
}
converter := wsync.NewCFIConverter(epubPath)
contextText := ""
if progress.ContextText.Valid {
contextText = progress.ContextText.String
}
pct := progress.Percentage.Float64
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
if err != nil {
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
return
}
if result != nil && result.XPointer != "" {
progressData.KoreaderXPointer = &result.XPointer
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
}
}
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string {
if h.libraryService == nil || epubcfi == "" {
return ""
}
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil || epubPath == "" {
return ""
}
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "")
if loc.Position != "" && loc.Position != epubcfi {
return loc.Position
}
return ""
}
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
@@ -693,14 +973,15 @@ 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)
}
}
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgItemUUID,
UserID: pgUserID,
})
@@ -800,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
position = fmt.Sprintf("page:%d", bookmark.Page)
}
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: bookmark.Text,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
"page": bookmark.Page,
})
if err == nil {
bookmarksSynced++
result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
MediaItemID: mediaItemID,
UserID: pgUserID,
Title: bookmark.Text,
Position: position,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: bookmark.Text,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
bookmarksSynced++
}
}
}
@@ -830,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
position = fmt.Sprintf("page:%d", note.Page)
}
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: note.Notes,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if h.annotationSvc != nil {
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime,
"pos0": note.Pos0,
"page": note.Page,
})
if err == nil {
notesSynced++
result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: note.Notes,
Position: position,
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
notesSynced++
}
} else {
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: note.Notes,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
notesSynced++
}
}
}
@@ -865,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
color = highlight.Color
}
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
Color: pgtype.Text{String: color, Valid: true},
})
if h.annotationSvc != nil {
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
if err == nil {
highlightsSynced++
pctStart := 0.0
if highlight.Percentage != nil {
pctStart = *highlight.Percentage
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": highlight.Datetime,
"pos0": highlight.Pos0,
"pos1": highlight.Pos1,
"page": highlight.Page,
})
result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: startPos,
EndPosition: endPos,
Color: color,
NoteText: highlight.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSynced++
}
} else {
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
Color: pgtype.Text{String: color, Valid: true},
})
if err == nil {
highlightsSynced++
}
}
}
+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)
+340 -60
View File
@@ -3,19 +3,23 @@ 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"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -60,33 +64,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 +136,8 @@ type MediaHandler struct {
worker *services.Worker
libraryService *services.LibraryService
searchService *services.SearchService
progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
}
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
@@ -138,6 +152,14 @@ func NewMediaHandler(db *database.Queries, libraryService *services.LibraryServi
return mh
}
func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
mh.progressSvc = svc
}
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
mh.annotationSvc = svc
}
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
bookUUID, err := uuid.Parse(c.Param("uuid"))
if err != nil {
@@ -548,7 +570,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 +765,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 +873,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 +926,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 +940,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 +978,94 @@ 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"`
ContextText *string `json:"context_text"`
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,
ContextText: req.ContextText,
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
}
if req.Percentage != nil && *req.Percentage < 0.005 {
existing, 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 && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
}
}
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 +1077,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 +1153,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 +1236,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 +1291,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)
}
@@ -1214,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
var note database.MediaNotes
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: req.Position,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
note = result.Note
} else {
var err error
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
return c.JSON(http.StatusCreated, note)
@@ -1237,7 +1434,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()})
@@ -1282,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if mh.annotationSvc != nil {
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
} else {
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
}
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -1351,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
color = req.Color
}
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaID,
UserID: pgUserID,
SelectionText: req.SelectionText,
StartPosition: req.StartPosition,
EndPosition: req.EndPosition,
Color: color,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, result.Highlight)
}
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgMediaID,
UserID: pgUserID,
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
@@ -1377,7 +1598,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()})
@@ -1439,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
if mh.annotationSvc != nil {
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -1684,3 +1914,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
}
+103 -25
View File
@@ -49,6 +49,73 @@ func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
return baseURL.Value, opdsBaseURL, nil
}
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
token := c.QueryParam("token")
if token == "" {
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
}
return token
}
func appendToken(url, token string) string {
if token == "" {
return url
}
if strings.Contains(url, "?") {
return url + "&token=" + token
}
return url + "?token=" + token
}
// resolveMimeType returns the mime type for a media item, preferring the stored
// mime_type, then format_mimetype, and finally falling back to EPUB.
func resolveMimeType(mime, formatMime pgtype.Text) string {
if mime.Valid && mime.String != "" {
return mime.String
}
if formatMime.Valid && formatMime.String != "" {
return formatMime.String
}
return "application/epub+zip"
}
// isComicArchive reports whether a format group represents a comic/manga
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
// and should not be offered as EPUB/KEPUB/PDF conversions.
func isComicArchive(formatGroup string) bool {
return strings.EqualFold(formatGroup, "comic_archive")
}
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
// file path's extension, defaulting to "epub" when it cannot be determined.
func formatLabelFromPath(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub":
return "epub"
case ".pdf":
return "pdf"
case ".cbz":
return "cbz"
case ".cbr":
return "cbr"
case ".cb7":
return "cb7"
case ".cbt":
return "cbt"
case ".mobi":
return "mobi"
case ".azw", ".azw3":
return "azw3"
case ".txt":
return "txt"
case "":
return "epub"
default:
return strings.TrimPrefix(ext, ".")
}
}
// GetDeviceCatalog returns the OPDS catalog feed for a device
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId")
@@ -140,11 +207,12 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
)
// Add feed links
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
token := h.getAuthToken(c)
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
// Add entries
@@ -170,16 +238,21 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String)
}
// Add acquisition links
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
// Add acquisition link using the item's real mime type
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
// Add format variants
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
// archives are served as-is in their native format.
if !isComicArchive(item.FormatGroup) {
if device.DeviceType == "kobo" {
kepubURL := downloadURL + "&format=kepub"
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
}
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
entry.AddAlternateLink(pdfURL, "application/pdf")
pdfURL := downloadURL + "&format=pdf"
entry.AddAlternateLink(pdfURL, "application/pdf")
}
// Add canonical identifier
entry.SetIdentifier(bookUUID)
@@ -194,7 +267,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)
}
}
@@ -267,10 +340,11 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
)
// Add feed links
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
token := h.getAuthToken(c)
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
// Add entries (same as catalog)
@@ -296,11 +370,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String)
}
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
// Only offer kepub conversion for ebooks; comic archives are served
// as-is in their native format.
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
kepubURL := downloadURL + "&format=kepub"
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
}
entry.SetIdentifier(bookUUID)
@@ -312,7 +390,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 +445,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 +592,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
}
@@ -599,7 +677,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
)
// Add feed links
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
@@ -655,7 +733,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
}
@@ -682,14 +760,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
formatList := []FormatInfo{}
// Add EPUB format (always available if media item exists)
// Add the primary/native format (always available if media item exists)
fileSize := int64(0)
if mediaItem.FileSize.Valid {
fileSize = mediaItem.FileSize.Int64
}
formatList = append(formatList, FormatInfo{
FormatType: "epub",
FormatType: formatLabelFromPath(mediaItem.FilePath),
FilePath: mediaItem.FilePath,
FileSha256: func() string {
if mediaItem.FileSha256.Valid {
@@ -791,7 +869,7 @@ func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
}
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
return c.JSON(http.StatusOK, map[string]interface{}{
"opds_token": map[string]interface{}{
+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)
})
}
}
+117 -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,
@@ -400,6 +417,25 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
}
}
if newBaseURL, ok := req["base_url"]; ok && newBaseURL != "" {
derivedConfigs := map[string]string{
"opds_base_url": newBaseURL + "/opds",
"api_base_url": newBaseURL + "/api",
}
for derivedKey, derivedValue := range derivedConfigs {
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
Key: derivedKey,
Value: derivedValue,
UpdatedBy: pgUserID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to update derived config key: %s", derivedKey),
})
}
}
}
// Check for HTMX request
if c.Request().Header.Get("HX-Request") == "true" {
// Fetch updated base_url for template
@@ -408,6 +444,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 +479,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 +527,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 +582,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)
+13 -4
View File
@@ -22,8 +22,8 @@ type Feed struct {
type Entry struct {
ID string `xml:"id"`
Title string `xml:"dc:title"`
Creator string `xml:"dc:creator,omitempty"`
Title string `xml:"title"`
Author *Author `xml:"author,omitempty"`
Updated string `xml:"updated"`
Summary string `xml:"summary,omitempty"`
Links []Link `xml:"link"`
@@ -32,6 +32,12 @@ type Entry struct {
Categories []Category `xml:"category,omitempty"`
}
type Author struct {
XMLName xml.Name `xml:"author"`
Name string `xml:"name"`
URI string `xml:"uri,omitempty"`
}
type Link struct {
Href string `xml:"href,attr"`
Type string `xml:"type,attr"`
@@ -85,14 +91,17 @@ func (f *Feed) AddEntry(entry Entry) {
// NewEntry creates a new OPDS entry
func NewEntry(id, title, creator, updated string) Entry {
return Entry{
e := Entry{
ID: id,
Title: title,
Creator: creator,
Updated: updated,
Links: []Link{},
Metadata: []Meta{},
}
if creator != "" {
e.Author = &Author{Name: creator}
}
return e
}
// AddAcquisitionLink adds an acquisition link to the entry
+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
+85 -2
View File
@@ -3,7 +3,10 @@ package router
import (
"context"
"log"
"net/url"
"time"
"bookhoard/internal/database"
"bookhoard/templates"
"github.com/google/uuid"
@@ -35,6 +38,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 +56,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
}, nil
}
@@ -58,7 +67,7 @@ func convertPending(pending []map[string]interface{}) []templates.PendingRegistr
RegistrationID: p["registration_id"].(string),
DeviceName: p["device_name"].(string),
DeviceType: p["device_type"].(string),
ExpiresAt: p["expires_at"].(string),
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
}
}
return result
@@ -78,5 +87,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{
+11 -2
View File
@@ -56,16 +56,20 @@ 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
AnnotationService *sync.AnnotationService
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
JobsHandler *handlers.JobsHandler
SidecarHandler *handlers.SidecarHandler
SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService
}
// createJWTMiddleware creates a JWT middleware with proper user context setup
@@ -90,7 +94,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),
@@ -186,6 +190,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
}
e.Validator = &CustomValidator{validator: v}
// Setup redirect middleware - must run before all routes
e.Pre(setupRedirectMiddleware(cfg))
// Rate limiter
rateLimiterConfig := ratelimit.RateLimiterConfig{
Enabled: cfg.Cfg.RateLimitEnabled,
@@ -204,12 +211,14 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
cfg.ScannerHandler = scannerHandler
// Register route groups
registerSetupRoutes(cfg)
registerAuthRoutes(cfg, rateLimitMiddleware)
registerLibraryRoutes(cfg)
registerDeviceRoutes(cfg)
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)
}
+60
View File
@@ -0,0 +1,60 @@
package router
import (
"bytes"
"context"
"log"
"net/http"
"strings"
"bookhoard/internal/setupstatus"
"bookhoard/templates"
"github.com/labstack/echo/v5"
)
func isSetupComplete(cfg *Config) bool {
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
}
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
path := c.Request().URL.Path
if path == "/setup" || path == "/setup/" {
return next(c)
}
if strings.HasPrefix(path, "/api/") {
return next(c)
}
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
return next(c)
}
if !isSetupComplete(cfg) {
return c.Redirect(http.StatusFound, "/setup")
}
return next(c)
}
}
}
func registerSetupRoutes(cfg *Config) {
e := cfg.Echo
e.GET("/setup", func(c *echo.Context) error {
if isSetupComplete(cfg) {
return c.Redirect(http.StatusFound, "/")
}
var buf bytes.Buffer
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
log.Printf("Failed to render setup template: %v", err)
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
}
return c.HTML(http.StatusOK, buf.String())
})
}
+3
View File
@@ -32,6 +32,9 @@ 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)
koboHandler.SetAnnotationService(cfg.AnnotationService)
koboHandler.SetLibraryService(cfg.LibraryService)
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")
}

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