Author SHA1 Message Date
john-okeefe 158fcb510b chore(templates): regenerate all templ Go files, add scan spinner to header
Regenerated templ output for all template files. Key source change:
- templates/header.templ: add scan progress spinner SVG and percentage
  display to header nav, initialize scan listener via x-init
2026-05-16 19:31:52 -04:00
john-okeefe 8cc9f75a1a fix(tests): protect dev admin from test cleanup, use isolated test names
Tests were deleting the development admin user, causing ON DELETE SET NULL
to cascade and set created_by_admin_id to NULL on all libraries.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also updated local templ CLI to v0.3.1020 to match.
2026-05-10 16:13:36 -04:00
john-okeefe 5bdddc6538 fix(templates): fix ErrorToast rendering literal { message } instead of error text
ErrorToast used Go string literal '{ message }' instead of templ
interpolation, so the actual error message was never shown — just the
literal text "{ message }" appeared in the toast.
2026-05-10 16:13:21 -04:00
john-okeefe be1c373b19 feat(metadata-editor): replace tags text input with badge picker + autocomplete
Replace the comma-separated text input for tags in the metadata editor
with a badge-based tag picker:
- Current tags shown as removable pill badges (✕ button per tag)
- Autocomplete input queries existing tags via shared tag-dropdown module
- Results shown as inline dropdown (not absolute, avoids overflow clipping
  from the modal's overflow-y-auto content area)
- Keyboard navigation: ArrowUp/Down, Enter to select, comma to add new,
  Escape to close
- Supports adding new tags not in the database (type + Enter/comma)
- Hidden data-editor-tag spans seed initial tags from server-side render
- collectFormData() now accepts editorTags array, skips the removed
  tags text input
2026-05-10 16:13:07 -04:00
john-okeefe fb9c471959 feat(bookshelf): replace broken datalist tag filter with custom autocomplete
The HTML <datalist> approach for tag autocomplete was unreliable across
browsers — showed empty suggestions or no dropdown at all.

Replace with a custom Alpine.js dropdown:
- New tag-dropdown.ts shared module: searchTagSuggestions() queries
  /api/media-items/search?tags=...&library_id=... and returns results
- Bookshelf: absolute-positioned dropdown below tags_filter input, shows
  tag name + book count per suggestion
- Keyboard navigation: ArrowUp/Down to highlight, Enter to select,
  Escape to close
- Click suggestion to populate the filter input
2026-05-10 16:12:44 -04:00
john-okeefe 3e5d3fe043 feat(book-detail): add tags and contributors display, fix series/tag links
- Add clickable tag badges between comic badges and synopsis, linking to
  /tags/detail?name=<tag>&library_id=<id> for browsing books by tag
- Add Contributors as comma-separated row in the metadata grid
- Add data-library-id attribute to body for tag autocomplete API calls
- Fix series badge link: append &library_id= so /series/detail works
  when navigated from book detail page (was returning empty/404)
2026-05-10 16:12:28 -04:00
john-okeefe b741f4f32d fix(search): add json tags to FieldValue struct for correct API response
FieldValue struct had no json tags, so Go marshaled fields as uppercase
(Value, Count, Score) but frontend expected lowercase (value, count).
This caused all autocomplete dropdowns (tags, author, series, language)
to silently fail — tagSuggestions[].value was undefined, crashing
toLowerCase() calls and producing empty dropdowns.
2026-05-10 16:12:10 -04:00
john-okeefe 647dec676f feat(db): add GetBooksByTag query for tag detail page
Uses $2 = ANY(tags) to match against the tags text[] column with GIN
index support. sqlc generates a single string Column2 param (not []string).
2026-05-10 16:11:46 -04:00
john-okeefe 0acacc1aa0 refactor(templates): generalize SeriesDetail into reusable BrowseDetail
Replace the single-purpose SeriesDetail template with a parameterized
BrowseDetail component that accepts badge icon/label, title, page title,
back URL/label, empty state icon/message, and book list. Both series
detail and new tag detail pages use the same template with different
params, eliminating duplication.

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

Deleted series_detail.templ and series_detail_templ.go.
Updated frontend.go series route to call BrowseDetail with series params.
Added /tags/detail route calling BrowseDetail with tag params.
2026-05-10 16:11:29 -04:00
john-okeefe 1724bc0767 feat(frontend): wire metadata editor Alpine component in book-detail.ts
Replace placeholder toast with full metadata editor Alpine data component:
- Modal show/hide (showMetadataEditor, hideMetadataEditor)
- Accordion section toggle
- Cover upload via FileReader preview
- Cover generation via dynamic cover-generator import
- Cover removal with placeholder fallback
- saveMetadata(): collects form data, sends PUT as JSON or multipart
  depending on whether a cover file is present
- Back button fix: skip overwriting sessionStorage back URL when
  referrer is the current page (preserves navigation after page reload)
2026-05-10 11:53:50 -04:00
john-okeefe 2ef1f9580f feat(frontend): add client-side cover generator via dynamic foliate-js import
New cover-generator.ts module that dynamically imports foliate-js/view.js
only when cover generation is requested, keeping it out of the main bundle.

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

Changes:
- Pass cMapUrl and standardFontDataUrl to view.open() in reader.ts
- Pin foliate-js fork to commit 74c317d in package.json for reproducibility
- Update build:ts script to copy cmaps/ and standard_fonts/ to
  web/static/vendor/pdfjs/ during build
2026-05-10 11:52:23 -04:00
john-okeefe 69872b48b5 fix(handlers): wire all 37 fields in UpdateMediaItem, add cover upload support
UpdateMediaItem handler:
- Add form: tags to UpdateMediaItemRequest for dual JSON/multipart binding
- Add 8 missing fields (Language, Edition, PageCount, Genre, CopyrightYear,
  GoodreadsID, OpenlibraryID, GoogleBooksID)
- Add CoverAction field (keep/upload/remove) with multipart cover handling
- Fetch existing record before update to preserve cover_image_path when
  cover_action is "keep" (was clearing cover on every JSON save)
- Add saveCoverImage() method: validates image type, resolves library path,
  saves as {file_path}.cover.jpg
- Add HX-Redirect response header for HTMX clients

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

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

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

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

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

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

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

Add seriesDetailPage Alpine component for the detail page's
library switcher (simple navigation, no AJAX needed).
2026-05-08 20:50:59 -04:00
john-okeefe 9471c4a599 fix(tests): URL-encode series name in special characters test
The TestGetSeries_SpecialCharactersInName test was failing with a 400
status because the series name 'Series: Book & Other (Vol. 1)' was
interpolated directly into the URL without encoding. The ampersand was
parsed as a query parameter delimiter, corrupting the request.

Use url.QueryEscape() to properly encode the name parameter.
2026-05-08 20:31:30 -04:00
john-okeefe 905a5d769c docs: add series cover layout mockup for reference 2026-05-08 20:28:02 -04:00
john-okeefe 9cc371572e chore: regenerate templ Go files (path reference update) 2026-05-08 20:27:50 -04:00
john-okeefe 9c1c5a66ac test(series): add unit and integration tests for series feature
Unit tests:
- series_service_test.go: test continueSeriesRowToMediaItems conversion
  with valid fields, null fields, and comprehensive field mapping
- series_test.go: test handler initialization and textToString helper

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

Update test helpers:
- Add SeriesHandler to setupTestServer router config
- Add 5th Continue Series collection to createDefaultCollectionsForUser
- Update dashboard integration test for 5 collections
2026-05-08 20:27:40 -04:00
john-okeefe 4cb72fc9ae feat(series): add AJAX library switching and stacked-cascade CSS
Create web/src/series.ts with Alpine component implementing the same
switchLibrary pattern as the dashboard:
- Fetch /api/series on library change instead of full page reload
- Fade out/in transition with loading spinner
- Re-render series grid and pagination from JSON response
- Save selected library to localStorage

Import series.ts in main.ts.

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

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

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

Add 'Continue Series' option to restore system collection modal.
2026-05-08 20:27:15 -04:00
john-okeefe 2fdc8751b4 feat(series): add SeriesHandler, API routes, and SSR browse page
Create SeriesHandler with two API endpoints:
- GET /api/series (paginated series list with covers)
- GET /api/series/books (books in a specific series)
Uses query param ?name=X instead of path param to avoid URL encoding
issues with special characters in series names.

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

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

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

Add SeriesCardData type to templates/types.go.

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

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

- GetDistinctSeries: list unique series with book counts, sorted by
  most recent entry, with pagination
- GetDistinctSeriesCount: total distinct series count for pagination
- GetSeriesCovers: fetch up to N cover image paths for a series,
  ordered by series_number
- GetSeriesBooks: fetch all books in a series ordered by series_number
- GetContinueSeriesItems: CTE-based query using DISTINCT ON to find
  the next unread book per series for a given user/library, sorted
  by most recent last_read_at
2026-05-08 20:26:39 -04:00
john-okeefe 06985474d0 fix(tests): correct date format in analytics reading stats tests
The GetReadingStats handler expects dates in MM-DD-YYYY format (01-02-2006)
but the tests were sending YYYY-MM-DD (2006-01-02), causing 400 errors on
the GetReadingStats_WithCustomDateRange and ReadingStats_FutureDateRange
test cases. Updated both test functions to use the matching format.
2026-05-01 16:59:49 -04:00
john-okeefe 8c7266e93b Merge branch 'main' of ssh://git.linuxhg.com:2222/Bookhoard/bookhoard 2026-05-01 14:31:54 -04:00
john-okeefe d22446d9b9 fix(library): sync allowed extensions across service, schema, and tests
Add .epub and .pdf to comics type, add .pdf to manga type, and ensure
avif/tiff/tif extensions are consistently included in manga across all
layers. Update test fixtures to match the canonical extension lists.
2026-05-01 14:31:17 -04:00
john-okeefe ae61acf478 chore: remove stale planning documents (PANEL_DETECTION_PLAN, PROGRESS_MIGRATION) 2026-05-01 14:31:13 -04:00
john-okeefe 19a85a3390 feat(ui): expand timezone dropdowns to cover all populated UTC offsets
Replaced the 8 US-centric timezone options with 24 entries covering
every populated UTC offset worldwide (UTC-10 through UTC+12). Each
option is labeled by regional name with UTC offset in parentheses,
e.g. 'Central European (UTC+1/+2)'. DST-shifting zones show both
standard and daylight offsets.

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

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

Updated in three locations:
- templates/profile_form.templ (user profile dropdown)
- templates/admin_settings.templ (admin settings dropdown)
- internal/handlers/sidecar.go (HTMX save response HTML)
2026-04-29 20:47:11 -04:00
john-okeefe 7534d7c30a feat(config): add TZ environment variable for container timezone
Adds TZ env var to docker-compose.yml app service (defaults to UTC)
and documents it in .env.example. This ensures the Go runtime's
time.Local is set correctly inside the container for any server-side
time operations that don't use an explicit timezone.
2026-04-29 20:33:13 -04:00
john-okeefe 55e668079c chore: regenerate all templ Go files
Regenerated from .templ sources after template changes. Includes
path reference updates in error messages (templates/ prefix
shortened) from templ tool regeneration.
2026-04-29 20:33:08 -04:00
john-okeefe be4ed15dad fix(profile): match timezone dropdown styling to rest of profile form
The timezone select used generic form-group/form-select CSS classes
while all other fields use Tailwind utilities with CSS custom
properties. Updated to use the same w-full px-3 py-2 border rounded
pattern with var(--bg-primary), var(--text-primary), and
var(--border) for visual consistency.
2026-04-29 20:33:03 -04:00
john-okeefe ad0bfcac2c fix(admin): wire up default timezone setting in admin settings page
The admin settings timezone dropdown was incomplete: it had no
pre-selection of the current value, was missing consistent styling,
and the form submission did not persist timezone changes.

Changes:
- frontend.go: load default_timezone from system_settings into the
  systemConfig map passed to the template
- admin_settings.templ: match card styling used by the Base URL
  section; pre-select current timezone with selected?= attribute
- sidecar.go: handle default_timezone in UpdateSystemConfiguration
  by writing to system_settings table instead of system_config;
  update HTMX response to include timezone section with current value
- Add selectedAttr() helper for HTMX HTML string response
2026-04-29 20:32:58 -04:00
john-okeefe 13edfdcf1a feat(ui): use timezone-aware time formatting across all templates
Replace hardcoded .Format() calls with FormatInTimezone() and
FormatTimestamptzInTimezone() helpers so all timestamps display in
the user's selected timezone.

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

Note: DatePublished is kept as a plain date format (MM-DD-YYYY) since
it is a pgtype.Date, not a timestamp, and does not need timezone
conversion.
2026-04-29 20:32:51 -04:00
john-okeefe 6b958cab39 feat(db): add timezone column to GetUser query
The GetUser query did not select the timezone column, so the router
helper could not access userDB.Timezone. Added u.timezone to the
SELECT list so the per-user timezone is available in the template
user context.
2026-04-29 20:32:42 -04:00
john-okeefe df989d4c8c fix(auth): resolve compile errors in timezone update handler
The timezone update block in UpdateProfile() referenced undefined
variables ctx and userUUID, causing a compile error. Fixed to use
c.Request().Context() and targetUserUUID which are the correct
variables in that handler scope.

Also added Timezone field to AdminUpdateUserRequest struct so the
timezone value is properly bound from JSON requests, since
UpdateProfile() binds to AdminUpdateUserRequest rather than
UpdateProfileRequest.
2026-04-29 20:32:38 -04:00
john-okeefe 59d0389d45 fix(ui): use timezone-aware formatting for Last Read timestamps in book detail and progress sync modal
Replace hardcoded 12-hour Format() calls with FormatTimestamptzInTimezone()
so that the Last Read time respects the user's selected timezone preference.
Both book_detail.templ and book_detail_modals.templ now use the same
timezone-aware helper that was introduced in the timezone support feature.
2026-04-28 21:12:01 -04:00
john-okeefe f69479db44 Update timezone plan: remove duplicate query, use 12-hour format
- Remove UpdateSystemTimezone query from plan; reuse existing
  UpdateSystemSetting with 'default_timezone' as the key parameter
- Update handler code example to reference UpdateSystemSetting
- Update FormatInTimezone format string to 12-hour (03:04 PM)
- Update queries file description in summary table
2026-04-27 21:31:31 -04:00
john-okeefe 17281e4ce7 Switch all user-facing time displays to 12-hour MM-DD-YYYY format
Consistently format dates and times across all templates and API
handlers using MM-DD-YYYY with 12-hour clock (03:04 PM):

- analytics.go: date keys, lastSync, lastRead timestamps
- progress.go: lastUpdated timestamp in GetAllProgress
- book_detail.templ: LastReadAt, DatePublished
- book_detail_modals.templ: progress sync timestamps, LastReadAt
- devices.templ: LastSync, LastSeen
- conflicts.templ: CreatedAt
- admin_users.templ: user CreatedAt date
2026-04-27 21:31:19 -04:00
john-okeefe 3b5af3beae Add timezone dropdown to profile form and admin settings
- Add timezone select dropdown to profile form with common US
  timezones and UTC
- Add system default timezone setting to admin settings page
- Reformat profile_form.templ with consistent indentation and
  multi-line attribute formatting
2026-04-27 21:31:04 -04:00
john-okeefe 330df97cfd Add timezone backend support (handlers, utilities, user context)
- Add FormatInTimezone and FormatTimestamptzInTimezone helpers
  in templates/utils.go for timezone-aware time display
- Add Timezone field to templates.User struct
- Pass user timezone from DB to template context in helpers.go
- Add timezone update handling in auth.go UpdateProfile with
  validation via time.LoadLocation
- Add UpdateTimezoneSettings handler in system_settings.go for
  admin system-wide default timezone using UpdateSystemSetting
2026-04-27 21:30:53 -04:00
john-okeefe 19701dc659 Add timezone support to database schema and queries
- Add timezone column (VARCHAR(50) DEFAULT 'UTC') to users table
- Add default_timezone row to system_settings seed data
- Add idx_users_timezone index for user timezone lookups
- Add UpdateUserTimezone and GetSystemTimezone queries
- Regenerate sqlc code (models, querier, queries.sql.go)
- Reuse existing UpdateSystemSetting for system timezone updates
  instead of creating a redundant UpdateSystemTimezone query
2026-04-27 21:30:37 -04:00
john-okeefe c3d900512c Remove completed PROGRESS_MIGRATION.md
The progress reading history migration has been fully implemented
and this planning document is no longer needed.
2026-04-27 21:30:22 -04:00
john-okeefe d97b144ce9 docs: add TIMEZONE_PLAN.md with full timezone implementation plan
Documents the approach for adding per-user timezone support with
system-wide fallback. The database already stores all timestamps as
UTC via TIMESTAMPTZ columns, so the work is primarily in the display
layer: user preference storage, timezone-aware template helpers, and
UI controls for selecting a timezone.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- QueueProcessor: syncProgress delegates to ProgressService.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Handle previously ignored error returns:

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

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

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

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

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

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

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

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

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

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

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

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

- Add shared test helpers (setupConflictTest, createTestConflict,
  makeConflictData) to reduce boilerplate across test files
- Split monolithic TestConflictDetection and TestConflictsBulkOperations
  into focused test functions per scenario
- Test conflict detection, bulk resolution (most_recent, highest_progress,
  manual strategies), and edge cases (empty IDs, invalid UUIDs,
  unauthorized access)
- Verify actual database state after resolution, not just HTTP response
2026-04-20 20:43:16 -04:00
john-okeefe a56061f2b3 test(handlers): add unit tests for conflict resolution logic
Add table-driven tests for the conflict handler's source selection
methods: GetMostRecentSource, GetHighestProgressSource, and
GetEarliestSource. Covers cases where each device wins, ties, and
missing/invalid data.
2026-04-20 20:43:04 -04:00
john-okeefe 533abaf9d7 refactor(docs): replace deprecated strings.Title with cases.Title
strings.Title has been deprecated since Go 1.18 because it does not
handle Unicode properly. Replace it with cases.Title from
golang.org/x/text which correctly handles language-specific title
casing. Applied to breadcrumb generation and document title formatting.
2026-04-20 20:42:58 -04:00
john-okeefe 441d30a63a chore: bump Go dependencies
- github.com/andybalholm/brotli 1.2.0 -> 1.2.1
- github.com/go-playground/validator/v10 10.30.1 -> 10.30.2
- github.com/jackc/pgx/v5 5.9.1 -> 5.9.2
- github.com/labstack/echo/v5 5.0.4 -> 5.1.0
- github.com/yuin/goldmark 1.7.17 -> 1.8.2
- golang.org/x/crypto 0.49.0 -> 0.50.0
- golang.org/x/text 0.35.0 -> 0.36.0
- golang.org/x/image 0.37.0 -> 0.39.0
- golang.org/x/net 0.52.0 -> 0.53.0
- golang.org/x/sys 0.42.0 -> 0.43.0
- Various indirect dependency updates
2026-04-20 20:42:52 -04:00
john-okeefe d4e65a79f9 docs: add fish-to-zsh conversion plan
Add a structured plan for translating Fish shell config.fish into
equivalent Zsh .zshrc syntax, preserving the existing Forge-managed
block in the target file.
2026-04-20 20:42:44 -04:00
232 changed files with 11660 additions and 33725 deletions
-15
View File
@@ -10,21 +10,6 @@ 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
-112
View File
@@ -1,112 +0,0 @@
name: Release
# Overrides the default run name (the tagged commit's message) so the Actions
# runs list shows "Release v0.3.0" instead.
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
# Publishes the Bookhoard container image to the Gitea container registry AND
# creates a Gitea Release whose body is the annotated tag's message (generated
# locally by `make release VERSION=...` via git-cliff). Triggered by a version
# tag push, or manually via workflow_dispatch with a tag. Pushing to main does
# nothing, so work-in-progress commits never ship. Each release publishes two
# image tags: the version (e.g. v0.3.0) and "latest".
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v0.3.0)'
required: true
type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
# Resolve the target tag for both triggers: explicit input on manual
# dispatch, otherwise the pushed tag ref.
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history ensures the tag annotation (the release notes) is present.
fetch-depth: 0
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
- 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 }}
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
password: ${{ secrets.REGISTRY_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:${{ env.TAG }}
git.linuxhg.com/bookhoard/bookhoard:latest
- name: Create Gitea Release
env:
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
# GITHUB_TOKEN cannot create releases on this instance. The PAT must
# carry write:repository scope. Idempotent: re-runs update an existing
# release for this tag instead of failing with 409. On any HTTP error
# the API response body is printed so a 403 names the missing scope.
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
: "${TAG:?TAG is required}"
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
AUTH="Authorization: token ${TOKEN}"
# Release body = the annotated tag's message (the git-cliff notes).
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
# Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases.
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
PAYLOAD=$(jq -n \
--arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
# POST/PATCH the release, surfacing Gitea's error message on failure
# (e.g. "token does not have write scope") instead of failing silently.
api_call() {
local method="$1" url="$2" resp code rbody
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d "${PAYLOAD}" "${url}")"
code="$(printf '%s' "${resp}" | tail -n1)"
rbody="$(printf '%s' "${resp}" | sed '$d')"
if [ "${code}" -ge 400 ]; then
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
return 1
fi
}
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
if [ -n "${EXISTING_ID}" ]; then
api_call PATCH "${API}/${EXISTING_ID}"
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
else
api_call POST "${API}"
echo "Created new release for ${TAG}"
fi
+4 -5
View File
@@ -8,15 +8,15 @@ 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
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/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 install
npm ci
# Copy Go mod files (cached unless go.mod changes)
COPY go.mod go.sum ./
@@ -36,8 +36,7 @@ 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 \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
CGO_ENABLED=0 GOOS=linux go build -a -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
+228 -657
View File
@@ -1,661 +1,232 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
bookhoard
Copyright (C) 2026 john-okeefe
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
bookhoard Copyright (C) 2026 john-okeefe
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+25 -60
View File
@@ -1,4 +1,4 @@
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick
# Include .env file for environment variables (single source of truth)
# Ignore if .env doesn't exist yet
@@ -7,14 +7,6 @@ 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,9 +36,6 @@ help:
@echo "Verification:"
@echo " make verify-guidelines - Run comprehensive guidelines check"
@echo " make verify-quick - Run quick guidelines check"
@echo ""
@echo "Release:"
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
# Run unit tests locally (fast, no containers)
test:
@@ -55,23 +44,23 @@ test:
# Run integration tests in containers (matches production environment)
test-integration:
@echo "Building test containers..."
$(COMPOSE) --profile tests build
podman compose --profile tests build
@echo "Starting application containers..."
$(COMPOSE) up -d db app
podman compose up -d db app
@echo "Waiting for services to be healthy..."
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
@until podman 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 $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
@until podman 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..."
$(COMPOSE) --profile tests run --rm tests
podman 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."
@@ -82,76 +71,76 @@ test-all: test test-integration
# Rebuild app container only (preserve DB, with cache)
rebuild-app:
@echo "Rebuilding app container (database stays running)..."
$(COMPOSE) up --build --force-recreate -d app
podman 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)..."
$(COMPOSE) build --no-cache app
$(COMPOSE) up --force-recreate -d app
podman compose build --no-cache app
podman 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)..."
$(COMPOSE) up --build --force-recreate -d
podman 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)..."
$(COMPOSE) build --no-cache
$(COMPOSE) up --force-recreate -d
podman compose build --no-cache
podman 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)..."
$(COMPOSE) down -v
$(COMPOSE) build --no-cache
$(COMPOSE) up --force-recreate -d
podman compose down -v
podman compose build --no-cache
podman compose up --force-recreate -d
@echo "✓ All containers rebuilt and restarted"
# Stop and remove containers
clean:
$(COMPOSE) down -v
podman compose down -v
# Quick start (if already built)
up:
$(COMPOSE) up -d
podman compose up -d
# Stop all containers (alias for clean)
down:
$(COMPOSE) down
podman compose down
# Restart app container (preserves database)
restart:
@echo "Restarting app container (database stays running)..."
$(COMPOSE) restart app
podman compose restart app
@echo "✓ App container restarted"
# Show container status
ps:
$(COMPOSE) ps
podman compose ps
# Show container logs
logs:
$(COMPOSE) logs -f
podman 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 $(COMPOSE) up --build --force-recreate -d
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
@echo "Waiting for services to be ready..."
@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
@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
@echo "✓ Test environment is ready!"
@echo "Application available at http://localhost:8765"
# Stop test environment
test-env-down:
$(COMPOSE) down -v
podman compose down -v
# Verify project guidelines compliance
verify-guidelines:
@@ -161,27 +150,3 @@ verify-guidelines:
verify-quick:
@echo "Running quick project guidelines verification..."
@./scripts/verify-quick.sh
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
# image and publishes a Gitea Release whose body is this tag's message. Notes come
# entirely from Conventional Commits — no hand-written message required.
#
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
# default cleanup would strip lines starting with "#").
#
# Requires git-cliff: https://git-cliff.org/install
# Usage: make release VERSION=v0.3.0
release:
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
@echo "Generating release notes for $(VERSION)..."
@git tag "$(VERSION)" HEAD && \
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
@git push origin "$(VERSION)"
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
+18 -20
View File
@@ -4,7 +4,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
## ✨ Why Bookhoard?
**🔄 Universal Sync**: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.
**🔄 Universal Sync**: Your reading progress, highlights, and notes sync automatically across all your devices - KOReader, Kobo, web, and mobile.
**📱 Multi-Library**: Organize your ebooks, comics, and manga with per-library folders and smart collections.
@@ -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://git.linuxhg.com/Bookhoard/bookhoard.git
git clone https://github.com/yourusername/bookhoard.git
cd bookhoard
# 2. Set up environment
@@ -35,10 +35,8 @@ cp .env.example .env
# DBPASS: openssl rand -hex 16
# Edit .env with your generated values
# 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")
# 3. Start the server
podman-compose up --build -d # or: docker-compose up --build -d
# 4. Open your browser
open http://localhost:8765
@@ -52,13 +50,13 @@ The first user to register automatically becomes an admin.
### Universal Cross-Platform Sync
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
- **Real-Time Progress**: Turn a page on your Kindle, see it on your phone
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
- **Offline Queue**: Changes sync when you reconnect, priority-processed
- **Conflict Resolution**: Smart handling when same book read on multiple devices
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
- **Format Conversion**: On-the-fly EPUB→KEPUB for Kobo devices
### Media Management
@@ -74,7 +72,7 @@ The first user to register automatically becomes an admin.
### Smart Collections
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
- **Device Shelf Mappings**: Sync collections to Kobo shelves and KOReader categories
- **Test Before Creating**: Preview which books match your rules
### Library Organization
@@ -102,8 +100,8 @@ The first user to register automatically becomes an admin.
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
@@ -111,18 +109,18 @@ The first user to register automatically becomes an admin.
### For Developers
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
- **[docs/contributing/DEVELOPMENT.md](docs/contributing/DEVELOPMENT.md)** - Development workflow
---
## 🎯 Supported Devices
| Platform | Sync | OPDS | Status |
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
| **Web Browser** | ✅ | ✅ | Full support |
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
| **Mobile Apps** | 🚧 | 🚧 | Android/iOS apps coming later |
| Platform | Sync | OPDS | Status |
| ---------------- | ---- | ---- | ------------------------ |
| **Web Browser** | ✅ | ✅ | Full support |
| **KOReader** | ✅ | ✅ | Kindle, Kobo, PocketBook |
| **Kobo Devices** | | | Clara, Libra, Sage, etc. |
| **Mobile Apps** | 🚧 | 🚧 | Coming Q2 2026 |
---
@@ -155,20 +153,20 @@ bruno run
## 📊 Project Status
**Version**: 1.0
**License**: AGPL-3.0
**License**: GPL-3.0
**Status**: Production-ready ✅
---
## 🤝 Contributing
We welcome contributions! Please see [docs/developer/development.md](docs/developer/development.md) for guidelines.
We welcome contributions! Please see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for guidelines.
---
## 📄 License
AGPL-3.0 - See [LICENSE](LICENSE) file for details.
GPL-3.0 - See [LICENSE](LICENSE) file for details.
---
+379
View File
@@ -0,0 +1,379 @@
# Timezone Implementation Plan
## Overview
Add per-user timezone support with system-wide fallback (set via docker-compose), defaulting to UTC. The database already stores all timestamps as UTC via `TIMESTAMPTZ` columns, so this is primarily a display-layer feature.
**Display format:** MM-DD-YYYY HH:MM (US convention, no timezone abbreviation shown)
**Timezone selection:** Manual dropdown only (no browser auto-detect)
---
## Phase 1: Database Schema
**File:** `database/schema/schema.sql`
1. Add `timezone` column directly to the `users` table definition (line ~36):
```sql
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
theme VARCHAR(50) DEFAULT 'tokyo-night',
max_devices INTEGER DEFAULT 10,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
timezone VARCHAR(50) DEFAULT 'UTC'
);
```
> Note: The `timezone` column is already present at line 36 in the current schema. No change needed for this step.
1. Add `default_timezone` to the `system_settings` INSERT block (line ~49-52):
```sql
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
('default_timezone', 'UTC', 'System default timezone')
ON CONFLICT (setting_key) DO NOTHING;
```
1. Add index in the indexes section (after line ~460, with other user indexes):
```sql
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
```
1. Regenerate sqlc code:
```bash
cd internal/database && sqlc generate
```
---
## Phase 2: Database Queries
**File:** `internal/database/queries/queries.sql`
Add new queries:
```sql
-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
```
> Note: `UpdateSystemTimezone` is omitted because the existing `UpdateSystemSetting` query handles it by passing `'default_timezone'` as the key parameter.
Regenerate after adding queries:
```bash
cd internal/database && sqlc generate
```
---
## Phase 3: Template Utilities
**File:** `templates/utils.go`
Add timezone-aware time formatting helpers:
```go
package templates
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM
func FormatInTimezone(t time.Time, timezone string) string {
if t.IsZero() {
return ""
}
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
return t.In(loc).Format("01-02-2006 03:04 PM")
}
// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone
func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string {
if !t.Valid {
return ""
}
return FormatInTimezone(t.Time, timezone)
}
```
---
## Phase 4: User Context Update
**File:** `templates/types.go`
Add `Timezone` field to the `User` struct:
```go
type User struct {
ID string
Email string
Username string
Role string
Theme string
FirstName string
LastName string
CreatedAt time.Time
Token string
Timezone string
}
```
**File:** `internal/router/helpers.go`
Update `getTemplateUserWithTheme()` to include timezone:
```go
userTimezone := "UTC"
if userDB.Timezone.Valid {
userTimezone = userDB.Timezone.String
}
return templates.User{
// ... existing fields ...
Timezone: userTimezone,
}
```
---
## Phase 5: Handlers
**File:** `internal/handlers/auth.go`
Update `UpdateProfileRequest` struct:
```go
type UpdateProfileRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
}
```
Add timezone update logic in `UpdateProfile()`:
```go
if req.Timezone != "" {
if _, err := time.LoadLocation(req.Timezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid timezone",
})
}
err := h.db.UpdateUserTimezone(ctx, database.UpdateUserTimezoneParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
})
if err != nil {
return err
}
}
```
**File:** `internal/handlers/system_settings.go`
Add timezone settings handler:
```go
type UpdateTimezoneSettingsRequest struct {
DefaultTimezone string `json:"default_timezone" validate:"required"`
}
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
var req UpdateTimezoneSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
}
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: req.DefaultTimezone,
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
```
---
## Phase 6: User Profile UI
**File:** `templates/profile_form.templ`
Add timezone dropdown after the theme field:
```templ
<div class="form-group">
<label for="timezone">Timezone</label>
<select name="timezone" id="timezone" class="form-select">
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (Coordinated Universal Time)</option>
<option value="America/New_York" selected?={ user.Timezone == "America/New_York" }>Eastern Time</option>
<option value="America/Chicago" selected?={ user.Timezone == "America/Chicago" }>Central Time</option>
<option value="America/Denver" selected?={ user.Timezone == "America/Denver" }>Mountain Time</option>
<option value="America/Los_Angeles" selected?={ user.Timezone == "America/Los_Angeles" }>Pacific Time</option>
<option value="America/Phoenix" selected?={ user.Timezone == "America/Phoenix" }>Mountain Time (no DST)</option>
<option value="America/Anchorage" selected?={ user.Timezone == "America/Anchorage" }>Alaska Time</option>
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii Time</option>
</select>
</div>
```
Include timezone in the HTMX form submission payload.
---
## Phase 7: Admin Settings UI
**File:** `templates/admin_settings.templ`
Add system default timezone setting:
```templ
<div class="setting-group">
<h3>System Defaults</h3>
<label for="default_timezone">Default Timezone</label>
<select name="default_timezone" id="default_timezone">
<option value="UTC">UTC (Coordinated Universal Time)</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="America/Phoenix">Mountain Time (no DST)</option>
<option value="America/Anchorage">Alaska Time</option>
<option value="Pacific/Honolulu">Hawaii Time</option>
</select>
</div>
```
---
## Phase 8: Template Time Display Updates
### Files to update
| Template | Line(s) | Field(s) |
| ------------------------------------ | -------------- | ----------------------------------- |
| `templates/book_detail.templ` | ~253, ~282 | `LastReadAt`, `DatePublished` |
| `templates/book_detail_modals.templ` | ~68, ~113 | `Timestamp`, `LastReadAt` |
| `templates/devices.templ` | ~83, ~91, ~174 | `LastSync`, `LastSeen`, `ExpiresAt` |
| `templates/conflicts.templ` | ~114 | `CreatedAt` |
| `templates/admin_users.templ` | ~89 | `CreatedAt` |
| `templates/queue.templ` | ~138 | `CreatedAt` |
### Change pattern
```templ
<!-- Before -->
{ book.ReadingProgress.LastReadAt.Time.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
```
For `time.Time` fields:
```templ
<!-- Before -->
{ device.LastSync.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatInTimezone(device.LastSync, user.Timezone) }
```
---
## Phase 9: Docker Configuration
**File:** `docker-compose.yml`
```yaml
services:
server:
environment:
- TZ=UTC
```
**File:** `.env.example`
```
# System default timezone (fallback if not set in DB)
TZ=UTC
```
---
## Files Modified Summary
| File | Change |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `database/schema/schema.sql` | Add timezone column to users, system_setting row |
| `internal/database/queries/queries.sql` | Add UpdateUserTimezone, GetSystemTimezone (reuses existing UpdateSystemSetting) |
| `templates/utils.go` | Add FormatInTimezone, FormatTimestamptzInTimezone |
| `templates/types.go` | Add Timezone field to User struct |
| `internal/router/helpers.go` | Pass timezone to template User |
| `internal/handlers/auth.go` | Handle timezone updates in UpdateProfile |
| `internal/handlers/system_settings.go` | Add timezone settings handler |
| `templates/profile_form.templ` | Add timezone dropdown |
| `templates/admin_settings.templ` | Add default timezone setting |
| `templates/book_detail.templ` | Update time displays |
| `templates/book_detail_modals.templ` | Update time displays |
| `templates/devices.templ` | Update time displays |
| `templates/conflicts.templ` | Update time displays |
| `templates/admin_users.templ` | Update time displays |
| `templates/queue.templ` | Update time displays |
| `docker-compose.yml` | Add TZ env var |
| `.env.example` | Add TZ example |
---
## Testing Checklist
- [ ] Create user, set timezone to Eastern, verify times display in MM-DD-YYYY HH:MM format
- [ ] Create user, set timezone to Pacific, verify different offset
- [ ] Test system default timezone fallback for users with no timezone set
- [ ] Verify invalid timezone values are rejected by the API
- [ ] Verify existing users (no timezone set) fall back to system default
- [ ] Verify all templates show consistent MM-DD-YYYY HH:MM format
- [ ] Run `make test-integration` to verify no regressions
---
## Deployment Steps
1. Update `database/schema/schema.sql` with new column and settings
2. Regenerate sqlc: `cd internal/database && sqlc generate`
3. Apply schema changes (restart database container with `make rebuild-force-db`)
4. Deploy backend code changes
5. Verify with existing data
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
-38
View File
@@ -1,38 +0,0 @@
info:
name: SetBaseUrl
type: http
seq: 3
http:
method: PUT
url: '{{base_url}}/api/system/config'
auth: inherit
body:
type: json
jsonBody: |-
{
"base_url": "http://localhost:8765"
}
headers:
- key: Authorization
value: Bearer {{token}}
- key: Content-Type
value: application/json
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Set Base URL
Configures the server's base_url during initial dev database setup.
Must be run after RegisterUser (which provides the auth token) and before
any library/device creation (which require setup to be complete).
**Method:** PUT
**Endpoint:** /api/system/config
**Auth:** Bearer token (from RegisterUser)
+1 -1
View File
@@ -47,7 +47,7 @@ docs: |-
- `id` (string, required): Media item UUID
**Request Body:**
- `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
- `rating` (number, required): Rating value (typically 1-5)
- `review` (string, optional): Review text
**Response:** Updated rating object
+3 -4
View File
@@ -83,10 +83,9 @@ docs:
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
Ratings (All Users)
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
- **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available.
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
Collections (All Users)
- **List Collections**: GET /api/collections - Get user's collections
- **Get Collection**: GET /api/collections/:id - Collection details with media items
-37
View File
@@ -1,37 +0,0 @@
# git-cliff configuration — generates the body of each Gitea Release from
# Conventional Commits accumulated since the previous tag. Invoked in CI by
# orhun/git-cliff-action with --latest so only the current tag's section is
# emitted (no full history, no header — the Gitea Release title is the tag).
# Docs: https://git-cliff.org/docs/configuration
[changelog]
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{% for commit in commits %}\
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
{% endfor %}\
{% endfor %}\
"""
trim = true
footer = ""
[git]
conventional_commits = true
filter_unconventional = false
require_conventional = false
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Tests" },
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
{ message = ".*", group = "Other" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"
+4 -66
View File
@@ -14,8 +14,6 @@ import (
"log"
"time"
_ "time/tzdata"
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
@@ -50,74 +48,24 @@ func main() {
}
log.Println("✅ Database schema initialized and verified, starting server...")
// Load tunable settings from the DB into the registry. All values fall back
// to compiled defaults if a row is missing, so this never blocks startup.
registry := database.NewSettingsRegistry(queries)
if err := registry.Load(ctx); err != nil {
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
}
// Wire the registry into the package-level password validator so live
// rule changes apply to the echo struct-tag validator and ValidatePassword.
middleware.SetDefaultPasswordSettings(registry)
// Seed base_url from env var if not already configured. Uses conditional
// UPDATE so admin-set values are never overwritten on restart.
if cfg.BaseURL != "" {
_, err = dbPool.Exec(ctx, `
INSERT INTO system_config (key, value)
VALUES ('base_url', $1)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value
WHERE system_config.value = ''
`, cfg.BaseURL)
if err != nil {
log.Printf("⚠️ Could not seed base_url: %v", err)
} else {
// Also seed derived URLs
for key, suffix := range map[string]string{
"opds_base_url": "/opds",
"api_base_url": "/api",
} {
_, _ = dbPool.Exec(ctx, `
INSERT INTO system_config (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value
WHERE system_config.value = ''
`, key, cfg.BaseURL+suffix)
}
}
}
// Create login attempt tracker from configured (or default) lockout policy.
loginMaxAttempts, loginLockout := registry.LoginLockout()
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute)
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
authHandler.SetSettings(registry)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
systemSettingsHandler.SetSettings(registry)
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
sidecarHandler.SetSettings(registry)
libraryHandler := handlers.NewLibraryHandler(queries)
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
deviceAuthMiddleware.SetSettings(registry)
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
hashConflictsHandler := handlers.NewHashConflictsHandler(queries)
// Create WebSocket connection manager
connManager := sync.NewConnectionManager()
progressService := sync.NewProgressService(queries, connManager)
annotationService := sync.NewAnnotationService(queries, connManager)
annotationService.SetSettings(registry)
maintenanceCancel := annotationService.StartDailyMaintenance()
defer maintenanceCancel()
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
queueProcessor := sync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService)
queueProcessor.SetAnnotationService(annotationService)
// Create library service
libraryService := services.NewLibraryService(queries)
@@ -126,23 +74,18 @@ func main() {
libraryService.SyncAllowedExtensions(context.Background())
// Create worker for background tasks
workerCfg := registry.WorkerPoolConfig()
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
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)
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
conversionService.SetSettings(registry)
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
opdsHandler.SetSettings(registry)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries)
@@ -151,7 +94,6 @@ func main() {
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)
@@ -195,7 +137,6 @@ func main() {
Echo: e,
Queries: queries,
Cfg: cfg,
Settings: registry,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
@@ -203,7 +144,6 @@ func main() {
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
ProcessingIssuesHandler: processingIssuesHandler,
HashConflictsHandler: hashConflictsHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
@@ -221,11 +161,9 @@ func main() {
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
JobsHandler: jobsHandler,
LoginTracker: loginAttemptTracker,
LibraryService: libraryService,
}
// Register all routes and get ebook handler
+2 -2
View File
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
// Create scanner and configure it
scanner := services.NewMediaScanner(setup.DB)
scanner.SetAdminID(adminID)
err = scanner.SetFolders([]string{tmpDir}, false)
err = scanner.SetFolders([]string{tmpDir})
require.NoError(t, err, "Failed to set scanner folders")
// Scan library
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
// Create scanner and configure it
scanner := services.NewMediaScanner(setup.DB)
scanner.SetAdminID(adminID)
err = scanner.SetFolders([]string{tmpDir}, false)
err = scanner.SetFolders([]string{tmpDir})
require.NoError(t, err, "Failed to set scanner folders")
// Scan library
-5
View File
@@ -84,7 +84,6 @@ type TestServerSetup struct {
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService
AnnotationService *wsync.AnnotationService
CleanupCancel context.CancelFunc
QueueCtx context.Context
QueueCancel context.CancelFunc
@@ -456,7 +455,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
cleanupCancel := connManager.StartCleanupTask()
progressService := wsync.NewProgressService(queries, connManager)
annotationService := wsync.NewAnnotationService(queries, connManager)
queueProcessor := wsync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService)
@@ -465,7 +463,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
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)
@@ -485,7 +482,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
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
@@ -541,7 +537,6 @@ func setupTestServer(t *testing.T) *TestServerSetup {
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
}
+11 -265
View File
@@ -45,47 +45,11 @@ CREATE TABLE IF NOT EXISTS system_settings (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Extend system_settings with typed metadata so it can back the admin UI's
-- configurable tunables. All columns are nullable for backward compatibility
-- with the original three rows and any pre-existing data.
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
-- Insert default system settings (original scan/timezone rows + tunables).
-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
-- ON CONFLICT DO NOTHING preserves any admin-modified values.
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
-- security / auth (live)
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
-- security / auth (restart required)
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
-- api (live)
('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
-- sync / performance (live)
('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
-- sync / performance (restart required)
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
-- 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'),
('default_timezone', 'UTC', 'System default timezone')
ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table
@@ -281,7 +245,6 @@ 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,
@@ -974,7 +937,6 @@ 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',
@@ -993,7 +955,6 @@ BEGIN
percentage,
character_offset,
epubcfi,
context_text,
chapter,
chapter_progress,
last_sync_device,
@@ -1010,7 +971,6 @@ 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',
@@ -1185,14 +1145,12 @@ CREATE TABLE IF NOT EXISTS system_config (
updated_by UUID REFERENCES users(id)
);
-- One-time cleanup: clear the old placeholder seed so the startup logic
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
UPDATE system_config SET value = ''
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
UPDATE system_config SET value = ''
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
UPDATE system_config SET value = ''
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
-- Pre-seeded values
INSERT INTO system_config (key, value) VALUES
('base_url', 'https://bookhoard.example.com'),
('opds_base_url', 'https://bookhoard.example.com/opds'),
('api_base_url', 'https://bookhoard.example.com/api')
ON CONFLICT (key) DO NOTHING;
-- Create opds_tokens table (device-specific OPDS access tokens)
CREATE TABLE IF NOT EXISTS opds_tokens (
@@ -1350,215 +1308,3 @@ 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;
-- ============================================
--: MEDIA ITEM DEDUPLICATION + PATH UNIQUENESS
-- ============================================
-- A read-then-write race in the scanner historically allowed the same
-- (library_id, file_path) to be inserted twice. This block is self-healing:
-- it collapses any existing path-duplicates (re-parenting child rows onto a
-- survivor so no reading history is lost), then enforces uniqueness going
-- forward. Idempotent — safe to re-run on every startup.
-- Move every child row that points at p_source so it points at p_target,
-- deleting source rows that would violate a UNIQUE constraint on the target.
CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN
RETURN;
END IF;
DELETE FROM reading_progress
WHERE media_item_id = p_source
AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target);
UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM reading_speed
WHERE media_item_id = p_source
AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target);
UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM media_ratings
WHERE media_item_id = p_source
AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target);
UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM media_bookmarks
WHERE media_item_id = p_source
AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target);
UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM media_item_formats
WHERE media_item_id = p_source
AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target);
UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM collection_items
WHERE media_item_id = p_source
AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target);
UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM kobo_shelves
WHERE media_item_id = p_source
AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target);
UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM panel_data
WHERE media_item_id = p_source
AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target);
UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM processing_issues
WHERE media_item_id = p_source
AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target);
UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source;
DELETE FROM device_file_aliases
WHERE media_item_id = p_source
AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target);
UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source;
-- Tables whose UNIQUE keys do not include media_item_id.
UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source;
UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source;
UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source;
UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source;
UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source;
UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source;
UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source;
END;
$$;
-- Collapse every (library_id, file_path) group into a single row.
-- Survivor = the row with the most user data; ties broken by lowest id.
CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
g RECORD;
v_surv UUID;
v_loser UUID;
BEGIN
FOR g IN
SELECT library_id, file_path
FROM media_items
GROUP BY library_id, file_path
HAVING COUNT(*) > 1
LOOP
SELECT mi.id INTO v_surv
FROM media_items mi
WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path
ORDER BY
((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id)
+ (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id)
+ (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id)
+ (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id)
+ (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id)
+ (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC,
mi.id ASC
LIMIT 1;
FOR v_loser IN
SELECT id FROM media_items
WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv
ORDER BY id
LOOP
PERFORM reparent_media_item_children(v_surv, v_loser);
DELETE FROM media_items WHERE id = v_loser;
END LOOP;
END LOOP;
END;
$$;
-- Collapse any existing path-duplicates so the constraint below can be created.
SELECT dedup_media_items_by_path();
-- Enforce path uniqueness going forward (guarded so re-runs don't error).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'media_items_library_id_file_path_key'
AND conrelid = 'media_items'::regclass
) THEN
ALTER TABLE media_items
ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path);
END IF;
END $$;
-- ============================================
--: HASH CONFLICTS
-- ============================================
-- Records content-duplicate groups discovered during hash backfill or rescan:
-- two or more media_items in the same library share a file_sha256 but live at
-- different file paths (e.g. the same book imported twice under two names on
-- a preexisting database). Unlike path duplicates these cannot be auto-collapsed
-- (keeping both copies may be intentional), so each group is surfaced on the
-- admin Hash Conflicts page for the user to resolve:
-- keep_all - both copies are intentional; just stop flagging
-- kept:<uuid> - merge every other copy's child rows into the kept item
-- (via reparent_media_item_children) and delete the losers
CREATE TABLE IF NOT EXISTS hash_conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
file_sha256 CHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')),
resolution VARCHAR(50), -- 'keep_all' or 'kept:<media_item_uuid>' (41 chars)
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
UNIQUE(library_id, file_sha256)
);
CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
-- Widen for databases created before the resolution format settled (no-op otherwise)
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
-57
View File
@@ -1,57 +0,0 @@
# 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
+55 -14
View File
@@ -1,3 +1,5 @@
version: "3.8"
services:
# PostgreSQL Database
db:
@@ -7,15 +9,14 @@ services:
POSTGRES_DB: bookhoard
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DBPASS}
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
PGPORT: ${DB_PORT:-5432}
COOKIE_SECURE: false # make true in production with HTTPS
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
# Make other volumes as needed
- ./uploads:/app/uploads
ports:
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
@@ -26,30 +27,27 @@ services:
- .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:
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
build:
context: .
dockerfile: ./Dockerfile
container_name: bookhoard
restart: unless-stopped
environment:
# Database Configuration
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_PORT: 5432
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: bookhoard
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: ${SERVER_PORT:-8765}
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: ${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}
BASE_URL: http://localhost:${SERVER_PORT}
# Rate Limiting Configuration
TEST_MODE: ${TEST_MODE:-false}
@@ -64,7 +62,7 @@ services:
# System timezone (fallback for server-side time operations)
TZ: ${TZ:-UTC}
ports:
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
- "8765:8765"
depends_on:
db:
condition: service_healthy
@@ -72,12 +70,55 @@ services:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost: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:
+24 -173
View File
@@ -26,16 +26,14 @@ Complete API documentation for Bookhoard v1.0 with Universal Cross-Platform Sync
8. [Device Management](#device-management)
9. [Analytics](#analytics)
10. [Book Matching & Linking](#book-matching--linking)
11. [Collections](#collections) → See [Collections API](collections-api.md)
11. [Collections](#collections) → See [COLLECTIONS_API.md](COLLECTIONS_API.md)
12. [OPDS](#opds-open-publication-distribution-system)
13. [Sync Protocol - KOReader](#sync-protocol---koreader)
14. [Sync Protocol - Kobo](#sync-protocol---kobo)
15. [Universal Progress](#universal-progress)
16. [Conflicts](#conflicts)
17. [Sync Queue](#sync-queue)
18. [System Settings & Configuration](#system-settings--configuration)
19. [Hash Conflicts](#hash-conflicts)
20. [WebSocket](#websocket)
18. [WebSocket](#websocket)
## Base URL
@@ -203,9 +201,7 @@ Content-Type: application/json
}
```
### Update Scan Settings (Legacy)
> Superseded by `PUT /api/system/settings` (see [System Settings & Configuration](#system-settings--configuration)); kept for backward compatibility.
### Update Scan Settings
```http
PUT /api/libraries/scan-settings
@@ -669,23 +665,18 @@ Content-Type: application/json
```json
{
"device_id": "uuid",
"registration_id": "registration-uuid",
"auth_url": "https://bookhoard.com/devices/approve/abc123",
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
"qr_code": "data:image/png;base64,iVBORw0KG...",
"expires_in": 300,
"poll_interval": 3,
"setup_instructions": {
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
}
"expires_in": 300
}
```
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes.
### Check Registration Status
```http
POST /api/devices/register/status
POST /api/devices/auth/status
Content-Type: application/json
{
@@ -697,13 +688,13 @@ Content-Type: application/json
```json
{
"status": "pending|approved",
"status": "pending|approved|expired",
"auth_token": "device-bearer-token...",
"device_id": "uuid",
"sync_endpoints": {
"progress": "https://bookhoard.com/api/sync/koreader/progress",
"metadata": "https://bookhoard.com/api/sync/koreader/metadata",
"bookmarks": "https://bookhoard.com/api/sync/koreader/bookmarks"
"progress": "https://bookhoard.com/api/sync/progress",
"metadata": "https://bookhoard.com/api/sync/metadata",
"annotations": "https://bookhoard.com/api/sync/annotations"
}
}
```
@@ -756,22 +747,6 @@ DELETE /api/devices/{device_id}
Authorization: Bearer <token>
```
### Get Device Sidecar Config
Returns the `.bookhoard.json` sidecar config for a device (server endpoints, books keyed by per-format SHA-256, collections) used by the KOReader plugin to self-configure.
```http
GET /api/devices/{device_id}/sidecar
Authorization: Bearer <token>
```
Also available as a file download:
```http
GET /api/devices/{device_id}/sidecar/download
Authorization: Bearer <token>
```
## Analytics
### Get Reading Statistics
@@ -978,7 +953,7 @@ Authorization: Bearer <token>
## Collections
For complete collection management documentation, see **[Collections API](collections-api.md)**.
For complete collection management documentation, see **[COLLECTIONS_API.md](COLLECTIONS_API.md)**.
**Quick Reference**:
@@ -1011,39 +986,25 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
- `page` (optional): Page number (default: 1)
- `per_page` (optional): Items per page (default: 50, max: 200)
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
walk pages by following the `rel="next"` link until it is absent. OpenSearch
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
**Response** (200 - OPDS 1.2 XML):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:opds="http://opds-spec.org/2010/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
xmlns:dc="http://purl.org/dc/elements/1.1/">
<id>urn:uuid:device-id</id>
<title>Bookhoard Library</title>
<updated>2026-02-01T12:00:00Z</updated>
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
<link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
<link rel="search" type="application/opensearchdescription+xml"
href="http://localhost:8765/opds/devices/kobo-id/search"/>
<opensearch:totalResults>1814</opensearch:totalResults>
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
<opensearch:startIndex>51</opensearch:startIndex>
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
<entry>
<id>urn:uuid:bookhoard-uuid-123</id>
<title>The Hobbit</title>
<author><name>J.R.R. Tolkien</name></author>
<dc:title>The Hobbit</dc:title>
<dc:creator>J.R.R. Tolkien</dc:creator>
<updated>2026-02-01T10:00:00Z</updated>
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
@@ -1082,28 +1043,10 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
### Search OPDS Catalog
```http
GET /opds/devices/{deviceId}/search # OpenSearch description
GET /opds/devices/{deviceId}/search?q={query} # search results feed
GET /opds/devices/{deviceId}/search?q={query}
```
When called **without** a `q` parameter, returns an OpenSearch description
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
learn the search URL template, then substitute `{searchTerms}`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Bookhoard</ShortName>
<Description>Search the Bookhoard library</Description>
<InputEncoding>UTF-8</InputEncoding>
<OutputEncoding>UTF-8</OutputEncoding>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
</OpenSearchDescription>
```
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
search results, including `opensearch:totalResults`).
**Response** (200 - OPDS 1.2 XML with search results)
### List Available Formats
@@ -1228,8 +1171,6 @@ Authorization: Bearer <device_token>
## Sync Protocol - Kobo
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
### Kobo Markup Sync
```http
@@ -1565,96 +1506,6 @@ Authorization: Bearer <token>
}
```
## System Settings & Configuration
### List All Settings
Returns every tunable setting with current value and metadata (type, range, category, group, description, `requires_restart`, `is_default`).
```http
GET /api/system/settings
Authorization: Bearer <admin_token>
```
**Response** (200):
```json
[
{
"key": "scan_poll_interval_seconds",
"value": "60",
"type": "int",
"min": "1",
"max": "3600",
"requires_restart": false,
"category": "scanner",
"group": "Scanning",
"description": "How often to scan all libraries (seconds)",
"is_default": true
}
]
```
### Update a Setting
Type-aware validation (int range, bool parse, IANA timezone for `default_timezone`), persists the value, reloads the registry, and reports whether a restart is needed.
```http
PUT /api/system/settings
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"key": "scan_poll_interval_seconds",
"value": "30"
}
```
**Response** (200): the updated entry plus `reload_required`.
Setting categories: scanner (`scan_poll_interval_seconds`, `auto_scan_enabled`), general (`default_timezone`), security (session duration, password rules, auth rate limit, login lockout), api (OPDS page sizes, device rate limits), sync (annotation tombstone TTL, sync queue interval/batch), performance (conversion cache TTL, worker pool size/capacity). See [System Settings API](api/system/settings.md) for the full catalog.
### Get / Update Raw System Config
Flat key/value configuration (e.g. `base_url`), including keys without registry metadata.
```http
GET /api/system/config
PUT /api/system/config
Authorization: Bearer <admin_token>
```
## Hash Conflicts
Duplicate content discovered during hashing (import, rescan, or the startup backfill) is grouped into hash conflicts for an explicit keep/merge decision. Files on disk are never deleted.
### List Hash Conflicts
```http
GET /api/admin/hash-conflicts
Authorization: Bearer <admin_token>
```
**Response** (200): `{ "conflicts": [ { id, library_id, library_name, sha256, created_at, items: [ { id, title, author, file_path, file_size, created_at, progress_count, highlight_count, bookmark_count, note_count, collection_count } ] } ], "total": n }`
### Resolve Hash Conflict
```http
POST /api/admin/hash-conflicts/:id/resolve
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"action": "keep",
"keep_uuid": "media-item-uuid-to-keep"
}
```
- `action=keep` — merge every other copy's child rows (progress, highlights, bookmarks, notes, collections) into the kept item, then delete the losers
- `action=keep_all` — copies are intentional; dismiss the conflict
**Errors**: `400` (bad ID / missing `keep_uuid`), `404` (not found), `409` (already resolved).
## WebSocket
### Connect to WebSocket
@@ -1810,10 +1661,10 @@ bruno run bruno/devices/
## Additional Resources
- [README.md](../../README.md) - Getting started guide
- [Sync Guide](../user/sync-guide.md) - Sync concepts and conflict resolution
- [KOReader Setup](../user/devices/koreader-setup.md) - KOReader device setup
- [Kobo Setup](../user/devices/kobo-setup.md) - Kobo device setup (native sync coming soon)
- [README.md](README.md) - Getting started guide
- [UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md](UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md) - Sync architecture
- [KOBOREADER_SETUP.md](KOBOREADER_SETUP.md) - KOReader device setup
- [KOBO_SETUP.md](KOBO_SETUP.md) - Kobo device setup
---
-111
View File
@@ -1,111 +0,0 @@
# Hash Conflicts API
## Overview
When Bookhoard hashes your library (on import, rescan, or the startup backfill), two media items in the same library with the same `file_sha256` indicate duplicate content. Each duplicate group is recorded as a **hash conflict** and exposed here for an explicit keep/merge decision. Conflicts are also surfaced in the admin UI's Hash Conflicts page.
**Authentication**: Admin JWT token required
**Content-Type**: `application/json` (resolve also accepts form-encoded bodies for htmx)
---
## Endpoints
### List Hash Conflicts
List all pending conflict groups, each with its member items and per-item usage counts (reading progress, highlights, bookmarks, notes, collections) to help decide which copy to keep.
**Endpoint**: `GET /api/admin/hash-conflicts`
**Response**: **200 OK**
```json
{
"conflicts": [
{
"id": "conflict-uuid",
"library_id": "library-uuid",
"library_name": "Ebooks",
"sha256": "abc123...",
"created_at": "2026-08-14T12:00:00Z",
"items": [
{
"id": "media-item-uuid",
"title": "The Hobbit",
"author": "J. R. R. Tolkien",
"file_path": "/books/hobbit.epub",
"file_size": 1048576,
"created_at": "2026-01-01T00:00:00Z",
"progress_count": 2,
"highlight_count": 12,
"bookmark_count": 3,
"note_count": 1,
"collection_count": 2
}
]
}
],
"total": 1
}
```
**Example**:
```bash
curl -X GET https://bookhoard.example.com/api/admin/hash-conflicts \
-H "Authorization: Bearer <admin_token>"
```
---
### Resolve Hash Conflict
Resolve one conflict group.
**Endpoint**: `POST /api/admin/hash-conflicts/{id}/resolve`
**Request Body** (JSON or form-encoded):
```json
{
"action": "keep",
"keep_uuid": "media-item-uuid-to-keep"
}
```
| Field | Type | Required | Description |
| ----------- | ------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `action` | string | Yes | `keep_all` — both copies are intentional; dismiss the conflict. `keep` — keep `keep_uuid` and delete the other copies. |
| `keep_uuid` | string | for `action=keep` | The media item UUID to keep. Must belong to this conflict group. With `keep`, every other copy's child rows (progress, highlights, bookmarks, notes, collections, …) are merged into the kept item before the losers are deleted. |
**Responses**:
- `200 OK` — resolved (body is an HTML confirmation snippet for the admin UI page)
- `400 Bad Request` — invalid conflict ID, missing `keep_uuid`, or `keep_uuid` not in the group
- `404 Not Found` — conflict doesn't exist
- `409 Conflict` — conflict already resolved
**Example**:
```bash
curl -X POST https://bookhoard.example.com/api/admin/hash-conflicts/<id>/resolve \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"action": "keep", "keep_uuid": "media-item-uuid"}'
```
---
## When Conflicts Are Created
- **Startup backfill**: items imported before hashing existed are hashed automatically ~30s after startup; duplicates discovered land here.
- **Rescan**: hashes are recomputed and content duplicates are flagged.
Files on disk are never deleted — resolution only affects database rows.
---
## Related Endpoints
- [System Settings API](../system/settings.md) — scanning configuration
- [Scanner API](../scanner/) — triggering scans and watch mode
+2 -26
View File
@@ -21,10 +21,9 @@ Complete reference for Bookhoard REST API endpoints.
- [Conflicts](conflicts/) - Sync conflict resolution
- [Queue](queue/) - Sync queue management
- [Scanner](scanner/) - Library scanning and watch mode (admin)
- [System](system/) - Tunable system settings and configuration (admin)
- [OPDS](opds/) - Open Publication Distribution
- [KOReader](koreader/) - KOReader sync protocol
- [Kobo](kobo/) - Kobo sync protocol (coming soon)
- [Kobo](kobo/) - Kobo sync protocol
- [WebSocket](websocket/) - Real-time sync events
---
@@ -52,8 +51,6 @@ See [Admin Operations](admin/)
- GET /api/auth/users - List all users (admin)
- PUT /api/auth/users/:id/max-devices - Update user device limit (admin)
- GET /api/admin/hash-conflicts - List pending hash conflict groups (admin) — see [Hash Conflicts](admin/hash-conflicts.md)
- POST /api/admin/hash-conflicts/:id/resolve - Resolve a conflict (keep / keep_all) (admin)
## Users & Profiles
@@ -74,10 +71,7 @@ See [Library Management](libraries/)
- DELETE /api/libraries/:id/folders - Delete library folder (admin)
- GET /api/libraries/:id/stats - Get library statistics (admin)
- GET /api/libraries/:id/media-items - Get library media items (admin)
- GET /api/libraries/browse - Browse server directories (admin)
- POST /api/libraries/:id/scan - Scan library (admin)
- GET /api/libraries/scan-settings - Legacy scan settings (admin; superseded by /api/system/settings)
- PUT /api/libraries/scan-settings - Legacy scan settings update (admin; superseded by /api/system/settings)
- GET /api/libraries/visibility - Get visible libraries
- POST /api/libraries/visibility - Set library visibility
@@ -107,10 +101,6 @@ See [Media Item Operations](media-items/)
- GET /api/media-items/:id/highlights/:highlightId - Get highlight
- PUT /api/media-items/:id/highlights/:highlightId - Update highlight
- DELETE /api/media-items/:id/highlights/:highlightId - Delete highlight
- GET /api/media-items/:id/bookmarks - Get bookmarks
- GET /api/media-items/:id/annotations/deleted - List deleted annotations (history)
- POST /api/media-items/:id/annotations/:annotationId/restore - Restore a deleted annotation
- DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark - Permanently delete a deleted annotation
- POST /api/media-items - Create media item (admin)
- PUT /api/media-items/:id - Update media item (admin)
- DELETE /api/media-items/:id - Delete media item (admin)
@@ -144,21 +134,10 @@ See [Device Registration & Sync](devices/)
- GET /api/devices/pending - List pending registrations (admin)
- GET /api/devices/approve/:registration_id - Approve registration (admin)
- POST /api/devices/reject/:registration_id - Reject registration (admin)
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
- POST /api/devices/:id/shelves - Add to shelf (Kobo)
- GET /api/devices/:id/shelves - Get shelf contents
- DELETE /api/devices/:id/shelves - Remove from shelf
- DELETE /api/devices/:id/shelves/clear - Clear shelf
- GET /api/devices/:id/sidecar - Get device sidecar config (.bookhoard.json) — see [Sidecar Config](devices/get_sidecar_config.md)
- GET /api/devices/:id/sidecar/download - Download sidecar config as a file
## System Settings & Configuration
See [System API](system/)
- GET /api/system/settings - List all tunable settings with metadata (admin)
- PUT /api/system/settings - Validate, persist, and reload a single setting (admin)
- GET /api/system/config - Raw key/value system configuration (admin)
- PUT /api/system/config - Update raw config values (admin)
## Analytics
@@ -241,15 +220,12 @@ See [OPDS Feeds](opds/)
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
- POST /api/sync/koreader/progress - Sync reading progress
- GET /api/sync/koreader/resolve?sha256={hash} - Resolve a book UUID by file SHA-256
- GET /api/sync/koreader/metadata/:uuid - Get book metadata
- GET /api/sync/koreader/library - Get device library
- POST /api/sync/koreader/bookmarks - Sync bookmarks
## Kobo Sync Protocol
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
- POST /api/sync/kobo/markup - Sync markup highlights
@@ -158,7 +158,7 @@ The frontend toast.js interceptor:
- **Backend**: Automatically manages HTTP-only cookie
- **Frontend**: Store tokens in localStorage for API calls
### Mobile Applications (coming later)
### Mobile Applications
- Store access token in secure storage (Keychain/Keystore)
- Store refresh token in secure storage
+1 -1
View File
@@ -2,7 +2,7 @@
Check device registration status or get device details.
**Endpoint**: `POST /api/devices/register/status` or `GET /api/devices/{device_id}`
**Endpoint**: `POST /api/devices/auth/status` or `GET /api/devices/{device_id}`
**Auth**: Not required for status check, Required for device details
**Content-Type**: `application/json` (for status check)
@@ -1,68 +0,0 @@
# Get Device Sidecar Config
Returns the KOReader/Kobo sidecar configuration (`.bookhoard.json`) for a device: server endpoints, the user's books (keyed by SHA-256 with UUID fallback), and collections. Used by the Bookhoard KOReader plugin to self-configure after approval.
**Endpoint**: `GET /api/devices/{id}/sidecar`
**Auth**: User JWT (device owner or admin)
### Response (200 OK)
```json
{
"version": "1",
"bookhoard": {
"opds_catalog": "https://bookhoard.example.com/opds/devices/<device-id>/catalog",
"sync_api": "https://bookhoard.example.com/api/sync/kobo",
"opds_base_url": "https://bookhoard.example.com/opds",
"api_base_url": "https://bookhoard.example.com",
"device_id": "<device-id>",
"device_token": "dev_..."
},
"books": {
"abc123sha256...": {
"bookhoard_uuid": "media-item-uuid",
"title": "The Hobbit",
"author": "J. R. R. Tolkien",
"available_formats": ["epub", "kepub"],
"sha256": "abc123sha256...",
"file_path": "/books/hobbit.epub"
}
},
"collections": [
{ "name": "Favorites", "shelf_mapping": "Favorites" }
],
"opds_enabled": true,
"sidecar_enabled": true,
"last_updated": "2026-08-20T12:00:00Z"
}
```
**Notes**:
- The `books` map is keyed by per-format SHA-256 (falling back to the item UUID), so a book downloaded in a different format (e.g. KEPUB) still matches its primary entry. Each entry lists `available_formats` for the item.
- `available_formats` includes `kepub` when the source is an EPUB (conversion available).
### Example Request
```bash
curl https://bookhoard.example.com/api/devices/<device-id>/sidecar \
-H "Authorization: Bearer <token>"
```
---
# Download Device Sidecar Config
Generates the same configuration as a downloadable `.bookhoard.json` file for manual device setup.
**Endpoint**: `GET /api/devices/{id}/sidecar/download`
**Auth**: User JWT (device owner or admin)
### Response (200 OK)
**Headers**:
- `Content-Type`: `application/json`
- `Content-Disposition`: attachment; filename="<device-name>.bookhoard.json"
**Body**: the sidecar JSON (same shape as above).
@@ -28,19 +28,14 @@ Register a new device for sync.
```json
{
"device_id": "uuid",
"registration_id": "registration-uuid",
"auth_url": "https://bookhoard.com/devices/approve/abc123",
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
"qr_code": "data:image/png;base64,iVBORw0KG...",
"expires_in": 300,
"poll_interval": 3,
"setup_instructions": {
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
}
"expires_in": 300
}
```
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes. Poll `POST /api/devices/register/status` at `poll_interval` seconds until `status` is `approved`, at which point the response includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
## Error Responses
| Code | Description |
@@ -1,7 +1,5 @@
# Analytics GetTests
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
Kobo analytics endpoint (device compatibility).
**Endpoint**: `POST /api/sync/kobo/v1/analytics/gettests`
-2
View File
@@ -1,7 +1,5 @@
# Bookmark Sync
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
Sync bookmarks from Kobo device.
**Endpoint**: `POST /api/sync/kobo/bookmark`
@@ -1,7 +1,5 @@
# Kobo Initialization
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
Initialize Kobo device sync.
**Endpoint**: `GET /api/sync/kobo/v1/initialization`
-2
View File
@@ -1,7 +1,5 @@
# Markup Sync
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
Sync markup highlights and annotations from Kobo device.
**Endpoint**: `POST /api/sync/kobo/markup`
@@ -1,7 +1,5 @@
# Sync From Server
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
Push content and metadata to Kobo device.
**Endpoint**: `POST /api/sync/kobo/sync-from-server`
@@ -1,50 +0,0 @@
# Resolve Book
Map a book's file SHA-256 to its Bookhoard UUID without touching progress
state. Used by devices to link a freshly downloaded book before their first
pull, so the device's first-page position is never pushed (which would
conflict with server-side progress for books already mid-read).
**Endpoint**: `GET /api/sync/koreader/resolve`
**Auth**: Required (Device authentication)
## Query Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------ |
| sha256 | string | Yes | File content hash (64 hex characters) |
Resolution is format-aware: the hash is checked against both
`media_items.file_sha256` and `media_item_formats.file_sha256`, so a
converted file (KEPUB/PDF) matches its media item too.
## Device Authentication
This endpoint requires device authentication (not user JWT). Devices
authenticate using their device credentials.
### Example Request
```http
GET /api/sync/koreader/resolve?sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Authorization: Bearer {device_token}
```
## Response (200 OK)
```json
{
"book_uuid": "550e8400-e29b-41d4-a716-446655440000",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"title": "Book Title",
"author": "Author Name"
}
```
## Error Responses
| Code | Description |
| ---- | -------------------------------------------- |
| 400 | Missing or malformed `sha256` parameter |
| 401 | Device authentication failed |
| 404 | No book in the library matches the given hash |
+36 -70
View File
@@ -1,81 +1,49 @@
# Sync Bookmarks
Sync bookmarks, notes, and highlights from a KOReader device (bidirectional — the response also returns the server's current state for the book so the device can reconcile).
Sync bookmarks from KOReader device.
**Endpoint**: `POST /api/sync/koreader/bookmarks`
**Auth**: Device token (Bearer)
**Auth**: Required (Device authentication)
## Device Authentication
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
## Request Body
| Field | Type | Required | Description |
| ------------ | ------ | --------------------- | ----------------------------------------------------------------- |
| book_uuid | string | one of uuid/sha | Book UUID (highest-confidence match) |
| book_sha256 | string | one of uuid/sha | Full-file SHA-256 (64 hex chars); format-aware (also matches `media_item_formats`, so a KEPUB/PDF download matches) |
| bookmarks | array | No | Bookmark objects |
| notes | array | No | Note objects |
| highlights | array | No | Highlight objects |
| Field | Type | Required | Description |
| --------- | ------------- | -------- | ------------------------- |
| device_id | string (UUID) | Yes | Device UUID |
| bookmarks | array | Yes | Array of bookmark objects |
At least one of `book_uuid` or `book_sha256` is required; `book_sha256` resolves through the shared BookResolver.
### Bookmark Object
### Bookmark / Note / Highlight Object
All three types share the same KOReader annotation shape:
| Field | Type | Required | Description |
| ------------ | ------- | -------- | ---------------------------------------------------- |
| chapter | int | No | Chapter index |
| datetime | string | No | ISO 8601 creation/edit timestamp |
| pos0 / pos1 | string | No | Start/end xpointer (or `page:N` / bare page) |
| page | int | No | Page number (fallback location when `pos0` is empty) |
| text | string | No | Highlighted text |
| notes | string | No | Note text attached to the annotation |
| type | string | No | Annotation type (`highlight`, `note`, `bookmark`) |
| color | string | No | Highlight color (highlights only) — KOReader palette name, see below |
| percentage | float | No | Position within the book (0-1) |
| book_sha256 | string | No | Per-annotation SHA-256; overrides the request-level book match |
| dedup_key | string | No | Stable echo key; an entry whose content is unchanged from what the server previously served is recognized as an echo rather than a new edit |
### Color Semantics
KOReader paints highlights from a fixed palette of color names; the web reader uses hex swatches. Colors are mapped at the boundary (unmappable values fall back to yellow on both sides):
| KOReader name | Web hex |
| ------------- | --------- |
| yellow, orange | `#ffd54f` |
| green, olive | `#a5d6a7` |
| cyan, blue | `#90caf9` |
| purple | `#ce93d8` |
| red | `#f48fb1` |
- An echo (device re-reporting an annotation it received from the server) carries **no color**, so the stored web color is never clobbered.
- A non-empty color means the user edited the highlight on the device; it is mapped to the nearest web swatch.
| Field | Type | Required | Description |
| ---------------- | ------- | -------- | -------------------------- |
| book | string | Yes | Book identifier |
| chapter | string | No | Chapter title |
| page | integer | No | Page number |
| position | float | Yes | Position in document (0-1) |
| notes | string | No | Bookmark notes |
| highlighted_text | string | No | Highlighted text |
| time | string | Yes | ISO 8601 timestamp |
| created_at | string | Yes | ISO 8601 timestamp |
### Example Request
```json
{
"book_sha256": "64-hex-char-sha256",
"device_id": "550e8400-e29b-41d4-a716-446655440000",
"bookmarks": [
{
"chapter": 3,
"datetime": "2026-08-20T10:00:00Z",
"pos0": "/body/Doc[4]/Sec[2]",
"book": "book.epub",
"chapter": "Chapter 1",
"page": 25,
"text": "",
"type": "bookmark",
"percentage": 0.125
}
],
"highlights": [
{
"datetime": "2026-08-20T10:05:00Z",
"pos0": "/body/Doc[4]/Sec[2]/text()[3]:0",
"pos1": "/body/Doc[4]/Sec[2]/text()[3]:42",
"text": "Text to remember",
"notes": "Why this matters",
"type": "highlight",
"color": "blue",
"dedup_key": "echo-key-from-server"
"position": 0.125,
"notes": "Important section",
"highlighted_text": "Text to remember",
"time": "2026-02-08T10:00:00Z",
"created_at": "2026-02-08T10:00:00Z"
}
]
}
@@ -85,17 +53,15 @@ KOReader paints highlights from a fixed palette of color names; the web reader u
```json
{
"sync_status": "ok",
"bookmarks_synced": 1,
"notes_synced": 0,
"highlights_synced": 1
"message": "Bookmarks synced successfully",
"synced_count": 1
}
```
## Error Responses
| Code | Description |
| ---- | -------------------------------------------------- |
| 400 | Invalid request, or neither uuid nor SHA provided |
| 401 | Missing/invalid device token |
| 404 | Book not found by SHA-256 |
| Code | Description |
| ---- | ---------------------------- |
| 401 | Device authentication failed |
| 400 | Invalid request data |
| 404 | Device not found |
@@ -1,84 +0,0 @@
# Deleted Annotations History
List, restore, or permanently delete tombstoned annotations (highlights,
notes, bookmarks) for a book. Deletions — from the web or propagated from a
synced device — are soft-deleted and retained for the sync retention window
(default 30 days), powering the book page's "Recently deleted" list. A
restore returns the row to the active set on every synced device; a purge
removes it immediately and irreversibly.
All endpoints require user JWT authentication and operate only on the
caller's own annotations.
## List Deleted Annotations
**Endpoint**: `GET /api/media-items/:id/annotations/deleted`
Returns tombstoned annotations for the book, newest deletion first.
### Response (200 OK)
```json
{
"deleted_annotations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"annotation_type": "highlight",
"display_text": "the chosen text",
"secondary_text": "user note",
"color": "#ffd54f",
"deleted_at": "2026-08-22T15:04:05Z",
"created_at": "2026-08-01T10:00:00Z"
}
],
"total": 1
}
```
| Field | Description |
| --------------- | ------------------------------------------------------ |
| annotation_type | `highlight`, `note`, or `bookmark` |
| display_text | Highlighted text / note content / bookmark title |
| secondary_text | Note text (highlights) or notes field (bookmarks) |
## Restore Deleted Annotation
**Endpoint**: `POST /api/media-items/:id/annotations/:annotationId/restore`
Body (or query param) `annotation_type` must be `highlight`, `note`, or
`bookmark`. Clears the tombstone; the annotation reappears in the active
set and re-syncs to devices on their next pull.
```json
{ "annotation_type": "highlight" }
```
### Response (200 OK)
```json
{ "restored": true }
```
404 when no matching *deleted* annotation exists for this user and book.
## Permanently Delete Annotation
**Endpoint**: `DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark`
Removes the tombstoned row from the history immediately. Irreversible —
unlike the tombstone itself, which is restorable until the retention window
lapses and the daily maintenance sweep purges it.
### Response (200 OK)
```json
{ "purged": true }
```
## Error Responses
| Code | Description |
| ---- | -------------------------------------------------- |
| 400 | Invalid IDs or missing/unknown `annotation_type` |
| 401 | Not authenticated |
| 404 | No matching deleted annotation |
-2
View File
@@ -1,7 +1,5 @@
# Kobo Sync Protocol
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change. Until then, KOReader (which runs on Kobo hardware) is fully supported.
Kobo uses a proprietary sync protocol with JSON payloads.
## Kobo Markup Sync
+14 -102
View File
@@ -17,31 +17,20 @@ KOReader uses a custom JSON-based sync protocol.
### Request Body
| Field | Type | Required | Description |
| ------------------ | ------- | -------- | ---------------------------------------------------- |
| library_id | string | No | Library UUID |
| books | array | Yes | Array of book sync data |
| books[].uuid | string | No\* | Book UUID (highest-confidence match; omitted on first sync of a newly downloaded book) |
| books[].sha256 | string | No\* | Full-file SHA-256 (64 hex chars); used to resolve the book when `uuid` is absent |
| books[].file_path | string | No | Device-local file path; used to create/look up a device file alias |
| books[].title | string | Yes | Book title |
| books[].authors | array | Yes | Array of author names |
| books[].progress | float | Yes | Progress percentage (0-1) |
| books[].percentage | float | Yes | Progress percentage (0-1) |
| books[].last_read | string | Yes | ISO 8601 timestamp |
| books[].chapter | integer | No | Current chapter |
| books[].epubcfi | string | No | EPUB CFI location |
| books[].character | integer | No | Character offset |
| books[].bookmarks | array | No | Array of bookmarks/highlights (shape, color mapping, and echo/dedup rules: see [Sync Bookmarks](../koreader/sync_bookmarks.md)) |
| books[].deleted_highlights | array | No | Highlights deleted on the device: `[{ "dedup_key": "..." }]` — keys previously served to this device (see [Deletion propagation](#deletion-propagation)) |
| books[].deleted_bookmarks | array | No | Bookmarks deleted on the device: `[{ "dedup_key": "..." }]` |
\* At least one of `uuid` or `sha256` should be present. The server resolves the
book through the shared `BookResolver` with this priority: `uuid``sha256`
`file_path` alias → `title`/`author`. SHA-256 matching is **format-aware**: it
checks `media_items.file_sha256` first, then `media_item_formats.file_sha256`, so
a converted file (e.g. KEPUB or PDF) downloaded via OPDS matches even though its
hash differs from the primary format's hash.
| Field | Type | Required | Description |
| ------------------ | ------- | -------- | ----------------------------- |
| library_id | string | No | Library UUID |
| books | array | Yes | Array of book sync data |
| books[].uuid | string | Yes | Book UUID |
| books[].title | string | Yes | Book title |
| books[].authors | array | Yes | Array of author names |
| books[].progress | float | Yes | Progress percentage (0-1) |
| books[].percentage | float | Yes | Progress percentage (0-1) |
| books[].last_read | string | Yes | ISO 8601 timestamp |
| books[].chapter | integer | No | Current chapter |
| books[].epubcfi | string | No | EPUB CFI location |
| books[].character | integer | No | Character offset |
| books[].bookmarks | array | No | Array of bookmarks/highlights |
### Example Request
@@ -96,41 +85,6 @@ hash differs from the primary format's hash.
}
```
## Book Resolution (UUID lookup)
**Endpoint**: `GET /api/sync/koreader/resolve?sha256={hash}`
**Auth**: Device token required
Read-only lookup mapping a file SHA-256 to the book's UUID (format-aware,
same `BookResolver` path as the progress push). Devices call this on the
first open of a newly downloaded book to learn the UUID **before** their
first pull. Full details: [Resolve Book](../koreader/resolve_book.md).
This matters for conflict avoidance: a device that pushes to bootstrap its
identity transmits its current (first-page) position, which the server
treats as a real progress update — overwriting/conflicting with genuine
mid-read progress from other sources. Resolve, then pull, then push.
### Example Request
```http
GET /api/sync/koreader/resolve?sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Authorization: Bearer device-token
```
### Response (200 OK)
```json
{
"book_uuid": "book-uuid",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"title": "Book Title",
"author": "Author Name"
}
```
404 when no book in the library matches the hash.
## KOReader Metadata Fetch
**Endpoint**: `GET /api/sync/koreader/metadata/{book_uuid}`
@@ -148,7 +102,6 @@ Authorization: Bearer device-token
```json
{
"uuid": "book-uuid",
"sha256": "ff3e4501bf9d72dea2ae28731a6cb5b83d7a7532c05b5d2dd083d0dbc9193ebf",
"title": "Book Title",
"authors": ["Author Name"],
"progress": {
@@ -166,44 +119,3 @@ Authorization: Bearer device-token
"last_sync": "2026-01-30T20:00:00Z"
}
```
`sha256` is the canonical primary-format hash of the book on the server. It is
returned so clients can cache it regardless of how the book was originally
obtained. The library list endpoint (`GET /api/sync/koreader/library`) includes
the same `sha256` field on each book.
## Deletion propagation
The progress push is upsert-only: absence of an annotation from
`highlights`/`notes`/`bookmarks` is **never** interpreted as a delete (a
client with a category disabled must not wipe the server). Deletions are
reported explicitly:
- Devices remember the `dedup_key` of every annotation the server served
them (persisted locally, e.g. KOReader's sidecar `bookhoard_known_keys`).
- When one of those annotations no longer exists locally, the next push
lists its key in `deleted_highlights` / `deleted_bookmarks`.
- The server tombstones the matching rows (`deleted = TRUE`, kept for the
retention window). Tombstones are served back to *other* devices via the
metadata fetch's `deleted_highlights` / `deleted_bookmarks` arrays so the
deletion converges everywhere.
- A stale replay pushing the annotation's content cannot resurrect the
tombstone: device pushes carry no modification timestamp, so the save is
treated as older than the delete.
- Restoring is possible from the web book page's deleted-annotation
history (`GET /api/media-items/:id/annotations/deleted`, restore/purge
endpoints) until the retention window lapses.
Because keys are only learned from server pulls, a device-native annotation
deleted locally is simply never pushed again — it can never be mis-flagged
as a server annotation deletion.
## Book identification
Every client/sync interface (KOReader, Kobo, OPDS, the device-link UI, and any
future mobile app) resolves books through a single shared service:
[`internal/services/book_resolver.go`](../../../internal/services/book_resolver.go).
The import-time SHA-256 (stored on `media_items.file_sha256`, plus a per-format
hash on `media_item_formats.file_sha256` for KEPUB/PDF) is the canonical shared
identifier. New clients should resolve by SHA-256 via `BookResolver` rather than
re-implementing their own matcher.
-77
View File
@@ -1,77 +0,0 @@
# System Config API
## Overview
Raw key/value system configuration storage (backed by the `system_config` table). Unlike the typed [System Settings API](settings.md), this endpoint reads and writes arbitrary config keys as plain strings — including keys without registry metadata, such as `base_url`.
**Base URL**: `/api/system`
**Authentication**: Admin JWT token required
**Content-Type**: `application/json`
---
## Endpoints
### Get System Configuration
Retrieve all system configuration entries as a flat key/value map.
**Endpoint**: `GET /api/system/config`
**Authentication**: Admin role required
**Response**: **200 OK**
```json
{
"base_url": "http://192.168.1.100:8765",
"default_timezone": "America/New_York"
}
```
**Example**:
```bash
curl -X GET https://bookhoard.example.com/api/system/config \
-H "Authorization: Bearer <admin_token>"
```
---
### Update System Configuration
Update one or more config values.
**Endpoint**: `PUT /api/system/config`
**Authentication**: Admin role required
**Request Body**: a flat map of keys to string values. Only the supplied keys are updated.
```json
{
"base_url": "https://bookhoard.example.com"
}
```
**Validation**: values for known keys are validated where applicable — for example, `default_timezone` must be a valid IANA timezone (`time.LoadLocation`); invalid values return `400` without persisting.
**Response**: **200 OK** on success; `400` (invalid value/format), `401`, `403`, `500` on failure.
**Example**:
```bash
curl -X PUT https://bookhoard.example.com/api/system/config \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"base_url": "https://bookhoard.example.com"}'
```
> **Note:** settings that appear in the typed settings registry (e.g. `default_timezone`) are better managed through [`PUT /api/system/settings`](settings.md), which also returns metadata and reload hints. Writes through either endpoint refresh the shared registry cache.
---
## Related Endpoints
- [System Settings API](settings.md) — typed, validated tunable settings with metadata
- `GET /api/devices/:id/sidecar` — device setup config derived from system config (see [Devices API](../devices/))
+109 -116
View File
@@ -1,10 +1,10 @@
# System Settings API
# System Scan Settings API
## Overview
The System Settings API is the canonical way to read and write Bookhoard's tunable system settings (scanning, security, rate limits, sync, performance, and defaults). Every setting carries full metadata — type, range, category, description, and whether a restart is required — so the admin UI (and API clients) can render and validate settings generically.
The System Scan Settings API allows administrators to configure system-wide scan settings that apply to all libraries. These settings control the automatic scanning behavior for the entire Bookhoard system.
**Base URL**: `/api/system`
**Base URL**: `/api/libraries`
**Authentication**: Admin JWT token required
**Content-Type**: `application/json`
@@ -12,61 +12,49 @@ The System Settings API is the canonical way to read and write Bookhoard's tunab
## Endpoints
### List All Settings
### Get System Scan Settings
Retrieve every known tunable setting with its current value and metadata.
Retrieve the current system-wide scan settings.
**Endpoint**: `GET /api/system/settings`
**Endpoint**: `GET /api/libraries/scan-settings`
**Authentication**: Admin role required
**Response**: **200 OK**
**Response**:
- **200 OK**: Returns current scan settings
- **401 Unauthorized**: Invalid or missing authentication
- **403 Forbidden**: User does not have admin role
- **500 Internal Server Error**: Server error
**Response Body**:
```json
[
{
"key": "scan_poll_interval_seconds",
"value": "60",
"type": "int",
"min": "1",
"max": "3600",
"requires_restart": false,
"category": "scanner",
"group": "Scanning",
"description": "How often to scan all libraries (seconds)",
"is_default": true
}
]
{
"scan_poll_interval_seconds": 60,
"auto_scan_enabled": true
}
```
**Entry fields**:
**Fields**:
| Field | Type | Description |
| ------------------ | ------- | -------------------------------------------------------- |
| `key` | string | Setting identifier (stable API name) |
| `value` | string | Current value (validated/clamped by the registry) |
| `type` | string | `int`, `bool`, or `string` |
| `min` / `max` | string | Range bounds for `int` settings (omitted otherwise) |
| `requires_restart` | boolean | Change takes effect only after a server restart |
| `category` | string | Coarse area: `scanner`, `security`, `api`, `sync`, `performance`, `general` |
| `group` | string | Sub-section shown in the admin UI |
| `description` | string | Human-readable description |
| `is_default` | boolean | True when the current value equals the compiled default |
- `scan_poll_interval_seconds` (integer): How often to poll for file changes in seconds (1-3600)
- `auto_scan_enabled` (boolean): Whether auto-scanning is enabled system-wide
**Example**:
```bash
curl -X GET https://bookhoard.example.com/api/system/settings \
curl -X GET https://bookhoard.example.com/api/libraries/scan-settings \
-H "Authorization: Bearer <admin_token>"
```
---
### Update a Setting
### Update System Scan Settings
Validate, persist, and reload a single setting.
Update the system-wide scan settings.
**Endpoint**: `PUT /api/system/settings`
**Endpoint**: `PUT /api/libraries/scan-settings`
**Authentication**: Admin role required
@@ -74,123 +62,128 @@ Validate, persist, and reload a single setting.
```json
{
"key": "scan_poll_interval_seconds",
"value": "30"
"scan_poll_interval_seconds": 30,
"auto_scan_enabled": true
}
```
| Field | Type | Required | Description |
| ------- | ------ | -------- | ------------------------------- |
| `key` | string | Yes | Setting key (from the list) |
| `value` | string | Yes | New value, as a string |
**Fields**:
**Response**: **200 OK**
- `scan_poll_interval_seconds` (integer, required): How often to poll for file changes in seconds
- Minimum: 1 (1 second)
- Maximum: 3600 (1 hour)
- Default: 60
- `auto_scan_enabled` (boolean, required): Whether auto-scanning is enabled system-wide
- Default: true
**Response**:
- **200 OK**: Settings updated successfully
- **400 Bad Request**: Invalid request parameters
- **401 Unauthorized**: Invalid or missing authentication
- **403 Forbidden**: User does not have admin role
- **500 Internal Server Error**: Server error
**Success Response Body**:
```json
{
"key": "scan_poll_interval_seconds",
"value": "30",
"type": "int",
"min": "1",
"max": "3600",
"requires_restart": false,
"category": "scanner",
"group": "Scanning",
"description": "How often to scan all libraries (seconds)",
"is_default": false,
"reload_required": false,
"message": ""
"scan_poll_interval_seconds": 30,
"auto_scan_enabled": true,
"message": "scan settings updated successfully"
}
```
- `reload_required: true` means the change takes effect only after a restart (e.g. rate limits, worker pool, lockout settings).
- Validation is type-aware: `int` values are checked against `min`/`max`, `bool` values must parse, `default_timezone` must be a valid IANA timezone via `time.LoadLocation`, and strings must be non-empty.
**Error Response Body**:
**Errors**: `400` (unknown key, invalid value, out of range), `401`, `403`, `503` (settings registry not initialized).
```json
{
"error": "error message"
}
```
**Validation Rules**:
- `scan_poll_interval_seconds` must be between 1 and 3600 seconds (1 second to 1 hour)
- Both fields are required
**Example**:
```bash
curl -X PUT https://bookhoard.example.com/api/system/settings \
curl -X PUT https://bookhoard.example.com/api/libraries/scan-settings \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"key": "scan_poll_interval_seconds", "value": "30"}'
-d '{
"scan_poll_interval_seconds": 30,
"auto_scan_enabled": true
}'
```
---
## Setting Catalog
## Behavior
Current tunable settings by category:
### Poll Interval
**Scanner** (`scanner`)
The `scan_poll_interval_seconds` setting determines how often the system will poll library folders for file changes as a fallback to real-time file watching.
| Key | Default | Range | Restart | Description |
| ------------------------------ | ------- | -------- | ------- | ----------------------------------------- |
| `scan_poll_interval_seconds` | `60` | 1-3600 | No | How often to scan all libraries (seconds) |
| `auto_scan_enabled` | `true` | - | No | Whether auto-scanning is enabled |
**Constraints**:
**General** (`general`)
- Minimum: 1 second
- Maximum: 3600 seconds (1 hour)
- Default: 60 seconds
| Key | Default | Restart | Description |
| ----------------- | ------- | ------- | ------------------------- |
| `default_timezone`| `UTC` | No | System default timezone |
### Auto-Scan Toggle
**Security** (`security`)
The `auto_scan_enabled` setting acts as a master switch for automatic scanning:
| Key | Default | Range | Restart | Description |
| ---------------------------- | --------- | ------------ | ------- | ---------------------------------------------- |
| `session_duration_seconds` | `604800` | 300-31536000 | No | How long a login session stays valid |
| `password_min_length` | `8` | 1-128 | No | Minimum password length |
| `password_require_upper` | `true` | - | No | Require at least one uppercase letter |
| `password_require_lower` | `true` | - | No | Require at least one lowercase letter |
| `password_require_number` | `true` | - | No | Require at least one number |
| `password_require_special` | `true` | - | No | Require at least one special character |
| `auth_rate_limit_per_min` | `10` | 1-10000 | **Yes** | Global auth API rate limit (req/min) |
| `login_max_attempts` | `5` | 1-100 | **Yes** | Failed login attempts before lockout |
| `login_lockout_minutes` | `15` | 1-10080 | **Yes** | Lockout duration after failed logins |
- When `true`: File watching and polling fallback are active for all libraries
- When `false`: No automatic file monitoring occurs (manual scans still available)
**API** (`api`)
### File Watching System
| Key | Default | Range | Restart | Description |
| ------------------------------- | ------- | --------- | ------- | ------------------------------------ |
| `opds_default_page_size` | `50` | 1-500 | No | Default OPDS page size |
| `opds_max_page_size` | `200` | 1-1000 | No | Maximum OPDS page size |
| `device_rate_sync_per_min` | `60` | 1-10000 | No | Device sync requests per minute |
| `device_rate_progress_per_min` | `120` | 1-10000 | No | Device progress requests per minute |
| `device_rate_metadata_per_min` | `30` | 1-10000 | No | Device metadata requests per minute |
The scan settings control the file watching system which consists of:
**Sync** (`sync`)
1. **Real-time file watching**: Uses fsnotify to detect file changes immediately
2. **Polling fallback**: If file watching fails or is unavailable, polls folders at the configured interval
| Key | Default | Range | Restart | Description |
| ------------------------------- | ------- | -------- | ------- | -------------------------------------------------- |
| `annotation_tombstone_ttl_days` | `30` | 1-3650 | No | How long deleted annotations are kept before purge |
| `sync_queue_interval_seconds` | `5` | 1-3600 | **Yes** | How often the sync queue flushes |
| `sync_queue_batch_size` | `50` | 1-10000 | **Yes** | Max items processed per sync queue flush |
**Performance** (`performance`)
| Key | Default | Range | Restart | Description |
| ------------------------ | ------- | --------- | ------- | ------------------------------------------- |
| `conversion_cache_ttl_hours` | `24` | 1-720 | No | How long converted (KEPUB) files are cached |
| `worker_pool_size` | `3` | 1-100 | **Yes** | Number of background worker goroutines |
| `worker_queue_cap` | `100` | 1-10000 | **Yes** | Background worker job queue capacity |
The system applies these settings to all configured libraries automatically on startup.
---
## Legacy Scan Settings Routes
## Error Codes
The older JSON routes still work for backward compatibility and now refresh the settings registry cache on write, but they are **superseded** by `GET/PUT /api/system/settings`:
- `GET /api/libraries/scan-settings` — returns only `scan_poll_interval_seconds` and `auto_scan_enabled`
- `PUT /api/libraries/scan-settings` — accepts `{ "scan_poll_interval_seconds": int, "auto_scan_enabled": bool }`
Both fields are backed by the same registry entries documented above.
| Status Code | Error Description |
| ----------- | ---------------------------------------------------------- |
| 400 | Invalid request parameters (e.g., frequency outside range) |
| 401 | Missing or invalid JWT token |
| 403 | User lacks admin role |
| 500 | Internal server error (e.g., database connection issue) |
---
## Related Endpoints
- `GET/PUT /api/system/config` — raw key/value system configuration (see [System Config API](config.md))
- `POST /api/scanner/scan` — trigger a manual scan (see [Scanner API](../scanner/))
- `GET /api/admin/hash-conflicts` — duplicates found during hashing (see [Hash Conflicts API](../admin/hash-conflicts.md))
- `POST /api/libraries/{id}/scan` - Manually trigger a scan for a specific library (admin only)
- `GET /api/libraries` - List all libraries
- `GET /api/libraries/{id}` - Get details for a specific library
---
## Migration Notes
This API has been updated to use a new polling-based scanning system. The following changes were made:
- **Changed**: `scan_frequency_minutes` renamed to `scan_poll_interval_seconds`
- **Changed**: Unit changed from minutes to seconds (15-1440 minutes → 1-3600 seconds)
- **Removed**: Old scheduler-based scanning system
- **Added**: Real-time file watching with polling fallback
- **Preserved**: API endpoint paths remain the same
The new system ensures that:
1. File changes are detected in real-time when possible (via fsnotify)
2. Polling fallback catches missed events at the configured interval
3. Settings apply to all libraries system-wide
4. Only administrators can modify scan settings
5. The `auto_scan_enabled` setting controls both file watching and polling
+14 -14
View File
@@ -11,8 +11,8 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
**[User Documentation Portal](user/user-guide.md)** - Guides for using Bookhoard features
- **Device Setup**
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Complete Kobo e-reader configuration
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook
- **Sync Configuration**
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
@@ -38,12 +38,12 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- [Queue API](developer/api/queue/) - Sync queue management endpoints
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints
- [WebSocket API](developer/api/websocket/) - Real-time sync events
- **Protocol Specifications**
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon)
- [WebSocket API](developer/websocket-api.md) - Real-time events
### 🔧 For Operations
@@ -62,8 +62,8 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
**[Contributing Portal](contributing/contributing.md)** - Development workflow
- [Development Guide](contributing/development.md) - Architecture, setup, testing
- [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) - Development rules and standards
- [Development Guide](contributing/Development.md) - Architecture, setup, testing
- [PROJECT_GUIDELINES.md](PROJECT_GUIDELINES.md) - Development rules and standards
---
@@ -75,7 +75,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
| **Contribute code** | [Contributing Portal → Development Guide](contributing/Development.md) |
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
---
@@ -87,13 +87,13 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| Question | Answer |
| --------------------------- | ------------------------------------------------------ |
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
| ...set up my Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) |
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
| ...use the API? | [API Reference](developer/api-reference.md) |
| ...contribute code? | [Development Guide](contributing/development.md) |
| ...contribute code? | [Development Guide](contributing/Development.md) |
### "Where is..."
@@ -112,7 +112,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
### Set up a new device
1. Choose your device: [KOReader](user/devices/koreader-setup.md) (works on Kindle, Kobo, and PocketBook hardware)
1. Choose your device: [Kobo](user/devices/kobo-setup.md) or [KOReader](user/devices/koreader-setup.md)
2. Understand sync: [Sync Guide](user/sync-guide.md)
3. Troubleshoot: Device-specific guides
@@ -128,7 +128,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
1. Follow [README.md](../README.md) quick start
2. Configure environment: [.env.example](../.env.example)
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
4. Check [Development Guide](contributing/development.md) for performance tuning
4. Check [Development Guide](contributing/Development.md) for performance tuning
---
@@ -138,12 +138,12 @@ When adding new features:
1. **User-facing features** → Update relevant User docs
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
3. **Backend changes** → Update [Development Guide](contributing/development.md)
3. **Backend changes** → Update [Development Guide](contributing/Development.md)
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
Keep [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) in mind for documentation standards.
Keep [PROJECT_GUIDELINES.md](PROJECT_GUIDELINES.md) in mind for documentation standards.
---
**Last Updated**: August 2026
**Last Updated**: 2026-02-08
**Bookhoard Version**: 1.0
+5 -5
View File
@@ -8,12 +8,12 @@ When creating or managing a library, you can add folders containing your media f
The admin library page includes a folder browser to help you select folders on the server:
1. Open the **Administration** panel in the sidebar (admins only) and go to **Libraries**
2. Click a library in the list to expand its panel
3. Find the **Folders** section
4. Click **Browse** next to the folder path input — this opens the **Browse Folders** dialog
1. Navigate to **Admin → Library Management**
2. Find the library you want to manage
3. Click the **Folders** button
4. Click **Browse** next to "Add folder path"
5. Navigate through the server's filesystem
6. Select a folder; it fills the path input, then click **Add**
6. Select a folder by clicking **Select This Folder**
### Security
+20 -20
View File
@@ -58,22 +58,20 @@ Calibre Library/
### Step 2: Add Library in Bookhoard
1. Open the **Administration** panel in the sidebar and go to **Libraries**
2. Click **Create Library**
1. Navigate to **Admin** **Libraries**
2. Click **Add Library**
3. Configure:
- **Library Name**: "My Calibre Library"
- **Description**: Optional
- **Library Type**: Ebook (or Audiobook/Comic)
4. Click the library in the list to expand its panel
5. Add your Calibre library folder in the **Folders** section:
- Enter the path (or click **Browse** to find it on the server) and click **Add**
6. Trigger a scan (see below), or rely on watch mode if enabled
- **Name**: "My Calibre Library"
- **Type**: Ebook (or Audiobook/Comic)
- **Folder**: Path to your Calibre library
- **Scan on save**: ✅ Checked
4. Click **Save**
Bookhoard scans the library and imports all books with their Calibre metadata. Scan progress shows in the sidebar next to the logo.
Bookhoard will automatically scan the library and import all books with their Calibre metadata.
### Step 3: Verify Import
1. Open the **Dashboard** or **All Books** page (sidebar navigation)
1. Navigate to **Library** view
2. Browse your imported books
3. Check that:
- Titles and authors are correct
@@ -138,8 +136,8 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Scenario**: You have a Calibre library with 500 ebooks, all organized with series, tags, and custom covers.
**Steps**:
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
2. Trigger a scan via the **Scanner API**, or let watch mode pick up the changed files (the File Watcher status is shown on the admin dashboard)
1. Add the Calibre library folder in Bookhoard
2. Enable "Scan on save"
3. Bookhoard imports all 500 books with:
- Correct titles and authors
- Series information (e.g., "Harry Potter #2")
@@ -168,8 +166,9 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Steps**:
1. Edit metadata in Calibre (it updates `metadata.opf`)
2. In Bookhoard, trigger a rescan:
- Via the **Scanner API** (`POST /api/scanner/scan`), or
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
- Navigate to **Admin****Libraries**
- Click **Rescan** on your library
- Or use the **Scanner API** to force rescan
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
**Result**: Bookhoard reflects your Calibre changes automatically.
@@ -183,7 +182,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Solutions**:
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically)
3. **Force rescan**: Use the "Force Rescan" option to re-import all metadata
4. **Check logs**: Review Bookhoard logs for parsing errors
### Incorrect Metadata
@@ -220,7 +219,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Do**:
- ✅ Edit metadata in Calibre
-Let Bookhoard's next scan (or watch mode) pick up the changes
-Rescan in Bookhoard to sync changes
- ✅ Use Calibre for library management
**Don't**:
@@ -260,11 +259,12 @@ Stay tuned for updates!
### OPDS Integration
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
- KOReader (Kindle, Kobo, PocketBook hardware)
You can access your Bookhoard library (including Calibre-imported books) via OPDS from Calibre-aware devices:
- Kobo e-readers
- KOReader
- Phone/tablet apps (KYBook, Chunky, etc.)
See the [KOReader Setup Guide](devices/koreader-setup.md) for details.
See the [Kobo Setup Guide](devices/kobo-setup.md) or [KOReader Setup Guide](devices/koreader-setup.md) for details.
## FAQ
+3 -19
View File
@@ -6,7 +6,7 @@ Collections allow you to organize books across multiple libraries.
### From Collections Page
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
Navigate to `/collections` to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
### From Dashboard
@@ -19,24 +19,8 @@ When viewing a specific library's dashboard, collections only show books from th
## Creating Collections
1. Go to **Collections** (sidebar navigation)
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
3. Fill in the details:
- **Name** - Collection name
- **Description** - Optional description
- **Icon** - Pick from the icon grid
- **Color** - Pick a color swatch
4. Click **Create Collection**
[Instructions for creating collections]
## Managing Collections
Each collection in the list has icon buttons on its card:
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save.
- **Delete** - Removes the collection after a confirmation prompt.
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections.
### Dashboard Sections
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)).
[Instructions for editing/deleting collections]
+13 -14
View File
@@ -16,27 +16,28 @@ Smart sections are automatically generated based on your reading activity:
### User Collections
Your collections appear as sections on the dashboard. To show or hide a collection's section:
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
1. Select the library in the **Library** bar
2. Click the **Customize Dashboard** icon button
3. Toggle the collection on or off
4. Click "Save Changes"
To enable a collection:
1. Go to Collections
2. Edit a collection
3. Toggle "Show on Dashboard"
4. Save
### Customizing Your Dashboard
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
3. **Drag sections** to reorder them
4. **Toggle visibility** with the switches
5. **Adjust items per section** (10-50 items)
6. Click "Save Changes"
1. Click the ⚙️ (gear icon) in the top-right
2. **Drag sections** to reorder them
3. **Toggle visibility** with the switches
4. **Adjust items per section** (10-50 items)
5. Click "Save Changes"
Settings are saved per library.
### Library Switching
Use the **Library** dropdown in the bar below the top bar to switch between libraries (including "All Libraries"). Each library has its own dashboard settings.
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
### Keyboard Navigation
@@ -44,8 +45,6 @@ Use the **Library** dropdown in the bar below the top bar to switch between libr
- **Arrow Keys**: Scroll carousels horizontally
- **Enter**: Open selected book
Hovering a carousel shows chevron buttons on either side for scrolling.
### Touch Gestures (Mobile)
- **Swipe**: Drag carousel left/right to scroll
+630 -22
View File
@@ -1,42 +1,650 @@
# Kobo Device Setup Guide
> ## 🚧 Coming Soon
>
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
This guide will help you set up your Kobo e-reader to sync with Bookhoard for seamless cross-device reading progress synchronization.
## Using a Kobo With Bookhoard Today
## What is Kobo Sync?
You don't have to wait: **KOReader runs on Kobo hardware** and syncs fully with Bookhoard today — reading position, bookmarks, highlights, and notes, plus OPDS wireless book delivery.
Bookhoard implements a Kobo-compatible sync protocol that allows your Kobo device to:
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
- Sync reading progress across all your devices
- Sync highlights and bookmarks
- Sync reading statistics
- Maintain device-specific metadata
## What's Planned for Native Kobo Sync
## Prerequisites
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard:
Before you begin, make sure you have:
- **Reading position sync** — percentages, pages, and reading statistics
- **Bookmarks, highlights, and notes** — synced with the web and other devices
- **OPDS wireless delivery** — browse and download books directly on the Kobo
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
- ✅ A Kobo e-reader device (Clara, Aura, Nia, Libra, Sage, Elipsa, etc.)
- ✅ A Bookhoard instance running and accessible on your network
- ✅ Your Bookhoard credentials (username and password)
- ✅ USB cable to connect your Kobo to your computer
- ✅ Your Kobo connected to the same Wi-Fi network as your Bookhoard instance
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
## Supported Kobo Devices
## FAQ
Bookhoard supports all Kobo devices that use the standard Kobo sync protocol:
**Q: Should I buy a Kobo to use with Bookhoard today?**
A: Kobo devices work great with Bookhoard via KOReader. Native (stock firmware) sync is coming soon.
- **Kobo Clara**: Clara 2E, Clara HD
- **Kobo Aura**: Aura, Aura H2O, Aura ONE, Aura Edition 2
- **Kobo Libra**: Libra 2, Libra H2O
- **Kobo Forma**: All versions
- **Kobo Sage**: All versions
- **Kobo Elipsa**: All versions
- **Kobo Nia**: All versions
- **Kobo Touch**: Touch 2.0
- **Kobo Glo**: Glo, Glo HD
**Q: What happens to my KOReader setup when native sync arrives?**
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
## Device Registration
### Step 1: Find Your Kobo Serial Number
1. Turn on your Kobo device
2. Go to **Settings** (gear icon)
3. Select **Device Information**
4. Note your **Device Serial Number** (e.g., N1234567890123)
- This is your device identifier for registration
### Step 2: Register Your Device in Bookhoard
1. Log in to your Bookhoard web interface
2. Navigate to **Device Management****Add New Device**
3. Fill in the device details:
- **Device Name**: A friendly name (e.g., "My Kobo Clara")
- **Device Type**: Select "Kobo"
- **Device Identifier**: Enter your Kobo serial number
4. Click **Register Device**
You'll receive:
- An **Auth URL** to approve the device
- Instructions for manual configuration
### Step 3: Approve Your Device
1. **Method A: QR Code**
- If displayed, scan the QR code with your phone's camera
- This will open the approval page in your browser
- Log in and click **Approve**
2. **Method B: Manual URL**
- Copy the Auth URL from the registration confirmation
- Open it in your web browser
- Log in to your Bookhoard account
- Click **Approve Device**
Your device is now registered and ready for configuration!
## Configure Kobo Sync
### Step 1: Connect Kobo to Your Computer
1. Use your USB cable to connect Kobo to your computer
2. Your computer should recognize Kobo as a storage device
3. Kobo will show "Connected" and "Eject before disconnecting"
### Step 2: Edit Kobo Configuration File
#### Windows Users
1. Open **File Explorer** and navigate to your Kobo device
2. Open the `.kobo` folder (hidden folder)
3. Open `Kobo/Kobo eReader.conf` in a text editor (Notepad++, VS Code, etc.)
#### Mac Users
1. Kobo device appears on your Desktop
2. Right-click the Kobo volume and select **Show Package Contents**
3. Navigate to `.kobo/Kobo/Kobo eReader.conf`
4. Open in a text editor (TextEdit, VS Code, etc.)
#### Linux Users
1. Kobo mounts at `/media/USERNAME/Kobo` or similar
2. Navigate to `.kobo/Kobo/Kobo eReader.conf`
3. Open in a text editor
### Step 3: Add Bookhoard Sync Configuration
After device registration is complete, you'll receive an API key and sync URL from Bookhoard.
Add the following section to the end of your `Kobo eReader.conf` file:
```ini
[FeatureSettings]
# Enable Kobo store replacement
KoboStoreSyncDisabled=true
[Sync]
# Bookhoard Sync Configuration (from Device Management page)
ServerURL=http://YOUR_COMPUTER_IP:8765/api/sync/kobo/YOUR_API_KEY
AutoSyncEnabled=true
SyncFrequency=5
```
**Where to find these values**:
- `YOUR_COMPUTER_IP`: Your Bookhoard server's IP address (e.g., 192.168.1.100)
- `YOUR_API_KEY`: Copy from Bookhoard Device Management → Your Kobo Device → "Copy Sync URL"
**Example configuration**:
```ini
[Sync]
ServerURL=http://192.168.1.100:8765/api/sync/kobo/dev_abc123def456
AutoSyncEnabled=true
SyncFrequency=5
```
**Important Notes**:
- The API key is generated during device registration
- You can regenerate the API key anytime from Device Management if needed
- Keep your API key confidential like a password
- Bookhoard uses revocable API keys for security (not username/password)
**Replace the following with your actual values**:
- `YOUR_COMPUTER_IP`: Your computer's local IP address (e.g., 192.168.1.100)
- `YOUR_BOOKHOARD_USERNAME`: Your Bookhoard email or username
- `YOUR_BOOKHOARD_PASSWORD`: Your Bookhoard password
**Example configuration:**
```ini
[Sync]
ServerURL=http://192.168.1.100:8765/api/sync/kobo
AutoSyncEnabled=true
SyncFrequency=5
Username=john@example.com
Password=securePassword123
```
### Step 4: Save and Eject
1. Save the `Kobo eReader.conf` file
2. Safely eject your Kobo device from your computer
3. Kobo will restart automatically
### Step 5: Verify Sync on Kobo
1. After Kobo restarts, go to **Settings****Sync & Backup**
2. You should see "Bookhoard" listed as a sync provider
3. Tap **Sync Now** to test the connection
4. If successful, you'll see a "Sync Complete" message
## Sync Features
### Reading Progress Sync
Kobo syncs:
- **Percentage Read**: Overall book completion percentage
- **Page Number**: Current page in fixed-layout books
- **Time Spent**: Reading time statistics
- **Last Read**: Timestamp of last reading session
### Annotations Sync
Kobo syncs:
- **Bookmarks**: Page positions saved for quick access
- **Highlights**: Highlighted text passages
- **Notes**: Notes attached to highlights
- **Reading Statistics**: Pages read, time spent
### Shelf Management
Kobo syncs:
- **Book Collections**: Your organized shelves
- **Shelf Contents**: Books in each collection
- **Sync Metadata**: When shelves were last updated
## OPDS Wireless Book Delivery
### What is OPDS?
OPDS (Open Publication Distribution System) allows your Kobo to **wirelessly download books** from Bookhoard - no USB cable needed!
### OPDS Benefits
- **No USB Required**: Download books directly to your Kobo over Wi-Fi
- **On-Demand Delivery**: Browse your Bookhoard library from your Kobo
- **Collection Support**: Download books from specific collections
- **Progress Tracking**: Books downloaded via OPDS sync progress automatically
- **Format Conversion**: Automatic EPUB to KEPUB conversion for better Kobo support
### Enable OPDS on Your Kobo
#### Option 1: Automatic Configuration (Recommended)
1. After registering your Kobo device, a **Download Configuration** button appears
2. Click **Download Configuration** to get a `.kobo` configuration file
3. Copy this file to your Kobo's `.kobo/` directory via USB
4. Eject and restart your Kobo
5. OPDS catalog will automatically appear in your Kobo's store
#### Option 2: Manual Configuration
1. Connect your Kobo to your computer via USB
2. Navigate to `.kobo/Kobo/Kobo eReader.conf`
3. Add the following configuration:
```ini
[FeatureSettings]
# Enable OPDS catalog
OPDSCatalogEnabled=true
OPDSCatalogURL=http://YOUR_COMPUTER_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog?token=YOUR_API_KEY
# Example:
# OPDSCatalogURL=http://192.168.1.100:8765/opds/devices/kobo-clara-123/catalog?token=dev_abc123def456
```
4. Replace:
- `YOUR_COMPUTER_IP`: Your Bookhoard server IP
- `YOUR_DEVICE_ID`: Your Kobo's device ID from Bookhoard Device Management
- `YOUR_API_KEY`: Your Kobo device's API key (same as in sync URL)
5. Save the file and safely eject your Kobo
### Access OPDS Catalog on Kobo
1. Wake your Kobo and connect to Wi-Fi
2. Go to **Home****Store** (or **Shop**)
3. You'll see **Bookhoard** listed as a store
4. Tap to enter the Bookhoard catalog
### Browse and Download Books
#### Browse All Books
1. In the Bookhoard catalog, you'll see all books from your library
2. Browse by:
- **Recently Added**: Latest books in your library
- **Collections**: Books organized by collections
- **Authors**: Books grouped by author
- **Series**: Books in reading order
#### Download a Book
1. Tap on any book cover to see details
2. Tap **Download** or **Add to Library**
3. The book downloads wirelessly to your Kobo
4. Progress bar shows download status
5. Once downloaded, the book appears in your **Home** library
#### Download from Collections
1. In the Bookhoard catalog, tap **Collections**
2. Select a collection (e.g., "Science Fiction")
3. Browse books in that collection
4. Tap to download individual books
5. Or tap **Download All** to get entire collection
### OPDS Features
#### Format Support
Kobo OPDS supports:
- **EPUB**: Standard ebook format (recommended)
- **KEPUB**: Kobo-optimized EPUB (better page turns, fonts)
- **PDF**: Fixed-layout documents
**Automatic Conversion**: Bookhoard automatically converts EPUB to KEPUB on-the-fly for better Kobo experience.
#### Progress Sync
Books downloaded via OPDS automatically sync progress:
1. Download a book via OPDS
2. Start reading on your Kobo
3. Progress syncs to Bookhoard automatically
4. Continue reading on any other device!
#### Collection to Shelf Mapping
Bookhoard maps your collections to Kobo shelves:
- Collection **"Science Fiction"** → Kobo shelf **"Sci-Fi"**
- Collection **"To Read"** → Kobo shelf **"To Read"**
- Customizable in Bookhoard Device Management
### OPDS Troubleshooting
#### Catalog Not Appearing
**Problem**: Bookhoard catalog doesn't show in Kobo store
**Solutions**:
1. Verify OPDS URL is correct in config file
2. Check Kobo is connected to Wi-Fi
3. Try accessing OPDS URL in your browser
4. Ensure device ID matches Bookhoard device ID
5. Restart Kobo after editing config file
#### Download Fails
**Problem**: Book download starts but fails partway through
**Solutions**:
1. Check Wi-Fi signal strength
2. Ensure Bookhoard server is running
3. Verify book file exists in Bookhoard library
4. Try downloading a smaller book first
5. Check Bookhoard logs for errors
#### Book Downloads But Won't Open
**Problem**: Downloaded book shows error when opening
**Solutions**:
1. Verify book format is supported (EPUB/KEPUB/PDF)
2. Check file isn't corrupted in Bookhoard
3. Try downloading via USB and opening
4. Check Kobo has sufficient free storage
5. Restart your Kobo device
#### Slow Download Speed
**Problem**: Books take too long to download
**Solutions**:
1. Ensure strong Wi-Fi signal (stay near router)
2. Use 5GHz Wi-Fi if your Kobo supports it
3. Close other apps using bandwidth
4. Download smaller books first
5. Consider using USB for large books
### OPDS vs USB Transfer
| Feature | OPDS (Wireless) | USB Transfer |
| -------------------- | ----------------------------- | ------------------------- |
| **Convenience** | ⭐⭐⭐⭐⭐ No cable needed | ⭐⭐ Requires cable |
| **Speed** | ⭐⭐⭐ Fast (Wi-Fi dependent) | ⭐⭐⭐⭐⭐ Very fast |
| **Bulk Transfer** | ⭐⭐⭐ One at a time | ⭐⭐⭐⭐⭐ Many at once |
| **Progress Sync** | ⭐⭐⭐⭐⭐ Automatic | ⭐⭐⭐⭐ After first sync |
| **Setup Complexity** | ⭐⭐⭐ Moderate | ⭐⭐⭐⭐⭐ Simple |
| **Reliability** | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ Excellent |
**Recommendation**: Use OPDS for convenience (1-5 books), use USB for bulk transfers (10+ books).
### Advanced OPDS Configuration
#### Custom Catalog Name
Change the name of the Bookhoard catalog on your Kobo:
```ini
[OPDS]
CatalogName=My Library
```
#### Auto-Download
Automatically download new books added to collections:
```ini
[OPDS]
AutoDownloadEnabled=true
AutoDownloadCollections=To Read,Recent
```
#### Download Quality
Choose between original EPUB or converted KEPUB:
```ini
[OPDS]
PreferredFormat=kepub # Options: epub, kepub, auto
```
## Sync Frequency Options
Configure how often Kobo syncs with Bookhoard:
```ini
[Sync]
# Sync frequency in minutes
SyncFrequency=5 # Sync every 5 minutes (recommended)
SyncFrequency=15 # Sync every 15 minutes
SyncFrequency=60 # Sync every hour
SyncFrequency=0 # Manual sync only
```
**Recommended**: `SyncFrequency=5` for near real-time sync
**Battery Saving**: `SyncFrequency=15` or `30` to reduce Wi-Fi usage
**Manual Only**: `SyncFrequency=0` sync only when you press "Sync Now"
## Manual Sync
To manually trigger a sync on your Kobo:
1. Connect Kobo to Wi-Fi
2. Go to **Settings****Sync & Backup**
3. Tap **Sync Now**
4. Wait for "Sync Complete" message
## Advanced Configuration
### Disable Kobo Store
To prevent Kobo from trying to connect to the official Kobo store:
```ini
[FeatureSettings]
KoboStoreSyncDisabled=true
```
### Custom Sync URL
If you're running Bookhoard with a custom domain or port:
```ini
[Sync]
# Custom domain
ServerURL=https://bookhoard.example.com/api/sync/kobo
# Custom port
ServerURL=http://192.168.1.100:9000/api/sync/kobo
# Localhost (for testing)
ServerURL=http://localhost:8765/api/sync/kobo
```
### HTTPS Configuration
If you have SSL/TLS configured on Bookhoard:
```ini
[Sync]
ServerURL=https://bookhoard.yourdomain.com/api/sync/kobo/YOUR_API_KEY
```
Replace `YOUR_API_KEY` with your device's API key from Bookhoard Device Management.
Kobo will automatically trust the certificate if properly configured.
## Troubleshooting
### Sync Not Working
**Problem**: Sync doesn't happen automatically
**Solutions**:
1. Check Kobo is connected to Wi-Fi
2. Verify `AutoSyncEnabled=true` in config
3. Check `SyncFrequency` is not set to 0
4. Test with manual sync first
5. Check Bookhoard logs for connection attempts
### Connection Refused
**Problem**: "Connection refused" or "Server not reachable"
**Solutions**:
1. Verify Bookhoard is running on your computer
2. Check the server URL and IP address are correct
3. Ensure Kobo is on same Wi-Fi network as computer
4. Temporarily disable firewall to test
5. Try accessing Bookhoard URL in your browser first
### Authentication Failed
**Problem**: "Authentication failed" or "Invalid API key"
**Solutions**:
1. Verify the API key in your sync URL matches the one in Bookhoard Device Management
2. Check that device is approved in Bookhoard (not pending)
3. Try regenerating the API key from Device Management page
4. Ensure the sync URL is complete (includes the API key)
5. Copy the sync URL directly from Device Management → "Copy Sync URL" button
### Configuration File Not Saving
**Problem**: Changes to `Kobo eReader.conf` are lost
**Solutions**:
1. Make sure Kobo is ejected safely after editing
2. Check file permissions (should be writable)
3. Try a different text editor (Notepad++, VS Code, Sublime Text)
4. Backup the file before editing
5. On Mac, ensure you're not editing the package directly
### Sync Only Works Manually
**Problem**: Manual sync works, but auto-sync doesn't
**Solutions**:
1. Verify `AutoSyncEnabled=true` in config
2. Check `SyncFrequency` is not 0
3. Kobo only syncs when connected to Wi-Fi
4. Some Kobo models require Wi-Fi to be manually connected
5. Check Bookhoard device management page for connection errors
### Books Not Appearing in Kobo
**Problem**: Books added to Bookhoard don't show on Kobo
**Solutions**:
1. Kobo needs books to be sideloaded (manually transferred via USB)
2. Bookhoard syncs PROGRESS, not book files
3. Transfer book files to Kobo's `Documents` folder via USB
4. Kobo will then sync progress for those books with Bookhoard
5. Check that book formats are supported by Kobo
### Conflicts Not Showing
**Problem**: Conflicts between devices aren't being detected
**Solutions**:
1. Check Bookhoard Conflicts page
2. Ensure both devices have synced recently
3. Conflicts only detected when progress differs within 5 minutes
4. Manually sync both devices to trigger conflict detection
5. Review conflict resolution settings
## Security Best Practices
1. **Use HTTPS**: If deploying Bookhoard publicly, configure SSL/TLS
2. **Strong Password**: Use a secure password for your Bookhoard account
3. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
4. **Regular Updates**: Keep Kobo firmware updated
5. **Device Authorization**: Only approve devices you recognize
## Network Configuration
### Local Network (Recommended)
For home use, keep Kobo and Bookhoard on the same local network:
```
Kobo Wi-Fi: 192.168.1.x
Bookhoard: 192.168.1.x
```
### Remote Access
For access outside your home network:
1. Set up port forwarding on your router (port 8765)
2. Configure SSL/TLS on Bookhoard
3. Use a dynamic DNS service for constant hostname
4. Update Kobo config with public URL including API key:
```ini
[Sync]
ServerURL=https://yourdomain.com/api/sync/kobo/YOUR_API_KEY
```
## Performance Optimization
### Battery Life
To extend Kobo battery life:
1. Use longer sync intervals (15-30 minutes)
2. Sync only on Wi-Fi (not cellular if your Kobo has it)
3. Disable unnecessary Kobo features
4. Keep Kobo in sleep mode when not reading
### Sync Speed
To improve sync speed:
1. Ensure strong Wi-Fi signal
2. Use local network (not remote access)
3. Keep Bookhoard and Kobo on same network
4. Close other apps using Wi-Fi bandwidth
5. Reduce number of books syncing at once
## Additional Resources
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
- [Kobo Developer Documentation](https://help.kobo.com/hc/en-us)
- [Bookhoard Universal Sync Guide](../sync-guide.md)
- [KOReader Setup Guide](koreader-setup.md)
- [Bookhoard API Reference](../../developer/api-reference.md)
## FAQ
**Q: Can I sync books (files) between devices?**
A: No, Bookhoard only syncs reading progress and annotations. You must sideload book files to each device manually.
**Q: Will Kobo update automatically when I add books in Bookhoard?**
A: No, Kobo doesn't fetch book files from Bookhoard. You must transfer books via USB.
**Q: Can I use both Kobo Sync and Calibre?**
A: Yes, but they may conflict. It's recommended to choose one sync method.
**Q: What happens if I read the same book on Kobo and KOReader?**
A: Bookhoard will detect conflicts and you can resolve them in the Conflicts UI.
**Q: Does Kobo sync when in sleep mode?**
A: Only if Wi-Fi is enabled and configured to stay active during sleep.
## Support
If you encounter issues:
1. Check the troubleshooting section above
2. Review Kobo sync logs in device settings
3. Check Bookhoard sync queue and device management pages
4. Verify your configuration file is saved correctly
5. Open an issue on the Bookhoard GitHub repository
---
**Last Updated**: August 2026
**Bookhoard Version**: 1.0
**Last Updated**: 2026-01-31
**Bookhoard Version**: 1.0
**Kobo Firmware**: 4.30.0+
+406 -70
View File
@@ -9,19 +9,18 @@ KOReader is an open-source e-reader application that supports a wide range of e-
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
- Kobo devices (Clara, Aura, Nia, etc.)
- PocketBook devices
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there.
- Android tablets and phones
## Prerequisites
Before you begin, make sure you have:
- ✅ A Bookhoard instance running and accessible on your network
-A web browser logged in to your Bookhoard account (for device approval)
-Your Bookhoard credentials (username and password)
- ✅ A KOReader-compatible e-reader device
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
## Installing KOReader
## Installation
### Kindle Devices
@@ -65,132 +64,469 @@ Before you begin, make sure you have:
- Open KOReader from your apps menu
- Enable Wi-Fi in the network settings
## Connecting KOReader to Bookhoard
## Device Registration
Setup is done **on the server**: you approve the device from the Bookhoard web interface — no usernames, passwords, or tokens to type on the device.
### Step 1: Get Your Bookhoard Instance URL
### Step 1: Install the Bookhoard Plugin
Find your Bookhoard instance URL. This will typically be one of:
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
2. Copy it to your KOReader `plugins/` directory
3. Restart KOReader
- **Local Network**: `http://YOUR_COMPUTER_IP:8765`
- **Localhost (if testing)**: `http://localhost:8765`
- **Domain (if configured)**: `https://bookhoard.yourdomain.com`
### Step 2: Point the Plugin at Your Server
### Step 2: Register Your Device in Bookhoard
1. Open KOReader, tap the **wrench icon** at the top
2. Find and tap **Bookhoard sync**
3. Tap **Server URL**, enter your server address, then tap **OK**:
1. Log in to your Bookhoard web interface
2. Navigate to **Device Management****Add New Device**
3. Fill in the device details:
- **Device Name**: A friendly name (e.g., "My Kindle Paperwhite")
- **Device Type**: Select "KOReader"
- **Device Identifier**: Enter your device's hardware ID or serial number
- On Kindle: Settings → Device Options → Device Info → Serial Number
- On Kobo: Settings → Device Information → Serial Number
4. Click **Register Device**
You'll receive:
- An **Auth URL** to approve the device
- A **Device Token** (automatically generated after approval)
### Step 3: Approve Your Device
1. **Method A: QR Code**
- If displayed, scan the QR code with your phone's camera
- This will open the approval page in your browser
- Log in and click **Approve**
2. **Method B: Manual URL**
- Copy the Auth URL from the registration confirmation
- Open it in your web browser
- Log in to your Bookhoard account
- Click **Approve Device**
Your device is now registered and ready to sync!
## Configure KOReader Sync
### Step 1: Access KOReader Settings
1. Open KOReader on your device
2. Tap the menu icon (≡) in the top-left corner
3. Select **Tools****Calibre**
### Step 2: Configure Wireless Connection
1. **Enable Calibre Wireless Connection**: Toggle ON
2. **Server Address**: Enter your Bookhoard instance URL
```
http://YOUR_COMPUTER_IP:8765
http://YOUR_COMPUTER_IP:8765/api/sync/koreader
```
Use your server's LAN IP (or domain if you have one configured).
Replace `YOUR_COMPUTER_IP` with your actual IP address
### Step 3: Approve the Device in Bookhoard
3. **Set Custom Port** (if needed): Keep default or enter `8765`
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation)
2. Refresh the page — your device appears under **Pending Device Registrations**
3. Click **Approve** to connect the device
### Step 3: Configure Authentication
Once approved, the plugin picks up its credentials automatically — reading progress sync and OPDS catalog access are set up automatically. No further configuration is needed.
1. **Authentication Method**: Select "Basic Auth"
2. **Username**: Your Bookhoard email or username
3. **Password**: Your Bookhoard password
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again.
### Step 4: Configure Sync Settings
### Auth Token (Advanced)
1. **Auto Sync**: Enable for automatic sync
2. **Sync Frequency**: Choose from:
- Every page turn (recommended for real-time sync)
- Every bookmark save
- Every highlight
- Manual only (sync when you press the sync button)
The Devices page shows each KOReader device's **Auth Token**. You normally never need it (the plugin receives it automatically during approval), but it can be re-entered manually in the plugin settings if you're moving a setup between devices or debugging.
3. **What to Sync**: Enable:
- ✅ Reading progress
- ✅ Bookmarks
- ✅ Highlights
- ✅ Notes
## What Syncs
### Step 5: Test Connection
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
1. Tap **Test Connection** in the Calibre settings
2. You should see a success message if configured correctly
3. If it fails:
- Verify your device is connected to Wi-Fi
- Check the server URL is correct
- Ensure your Bookhoard instance is running
- Verify username and password are correct
- **Reading position** — percentage, chapter, and EPUB CFI where available
- **Bookmarks**
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes
- **Notes** — standalone and attached to highlights
## Using Sync Features
Books are matched automatically using UUIDs, file hashes (SHA-256, format-aware so converted files still match), file aliases, and title/author fallback. If a book can't be matched, it shows up under the device's **Unlinked Books** in Bookhoard, where you can link it manually.
### Initial Sync
When you first enable sync, KOReader will:
1. Connect to Bookhoard
2. Upload your current reading progress
3. Download any annotations from the server
4. Set up bidirectional sync for future changes
### Reading Progress Sync
As you read:
- Progress updates automatically sync based on your sync frequency
- Page turns, chapter changes, and bookmark saves all trigger sync
- Sync occurs in the background without interrupting reading
### Annotations Sync
- **Bookmarks**: Sync when created or deleted
- **Highlights**: Sync when created, edited, or deleted
- **Notes**: Sync when created, edited, or deleted
- **Linked Notes**: Notes attached to highlights sync together
### Manual Sync
To manually trigger a sync:
1. Open the KOReader menu (≡)
2. Select **Tools** → **Calibre**
3. Tap **Sync Now**
The sync status will display:
- 🟢 **Synced** - All changes uploaded
- 🟡 **Syncing...** - In progress
- 🔴 **Failed** - Check your network connection
## Advanced Configuration
### Offline Mode
KOReader automatically handles offline scenarios:
1. Changes are queued locally when offline
2. Auto-sync resumes when connected
3. Queue processes all pending changes in priority order
### Checkpoint Sync
For better battery life, use checkpoint mode:
1. In KOReader Calibre settings
2. Set **Sync Mode** to "Checkpoint"
3. Set **Checkpoint Interval** (e.g., every 5 minutes)
4. Syncs occur in batches instead of every action
### Debug Mode
Enable debug logging if sync isn't working:
1. KOReader menu → Tools → Calibre
2. Enable **Debug Logging**
3. Sync and check logs at `/mnt/us/koreader/calibre.log`
## OPDS Wireless Book Delivery
Once your device is approved, the plugin also registers Bookhoard's OPDS catalog, so you can browse and download books wirelessly — no USB cable needed.
### What is OPDS?
OPDS (Open Publication Distribution System) allows your KOReader device to **wirelessly download books** from Bookhoard - no USB cable needed!
### OPDS Benefits
- **Wireless Downloads**: Browse and download books over Wi-Fi
- **On-Demand Access**: Your entire library at your fingertips
- **Collection Support**: Browse and download from specific collections
- **Automatic Progress Sync**: Downloaded books sync progress instantly
- **Format Support**: EPUB, KEPUB, PDF, and more
### Enable OPDS in KOReader
#### Step 1: Get Your OPDS URL
1. Log in to Bookhoard web interface
2. Go to **Device Management**
3. Find your registered KOReader device
4. Click **Show OPDS URL**
5. Copy the URL (format: `http://YOUR_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog`)
#### Step 2: Add OPDS Catalog in KOReader
1. Open KOReader on your device
2. Tap the **+** (plus) button on the home screen
3. Select **OPDS Catalog**
4. Enter catalog details:
- **Name**: Bookhoard (or any name you prefer)
- **URL**: Paste your OPDS URL from Step 1
5. Tap **Save**
Your Bookhoard library now appears in KOReader's home screen!
### Browse and Download Books
1. In KOReader, open the OPDS catalog list and tap **Bookhoard**
2. Browse your library: all books, collections, and recent additions
3. Tap a book to see details and **Download** it
#### Browse Your Library
### Supported Formats
1. Tap **Bookhoard** on KOReader home screen
2. You'll see:
- **All Books**: Complete library view
- **Collections**: Books organized by collections
- **Recent**: Latest additions
3. Tap any category to browse
#### Download a Book
1. Browse to find a book
2. Tap the book to see details
3. Tap **Download**
4. Progress bar shows download status
5. Book opens automatically when complete
#### Download Entire Collections
1. In Bookhoard catalog, tap **Collections**
2. Select a collection
3. Tap **Download All** to get all books
4. Downloads queue and process in background
### OPDS Features
#### Supported Formats
KOReader OPDS supports:
- **EPUB**: Standard ebook format
- **KEPUB**: Kobo-optimized format
- **KEPUB**: Kobo-optimized format (KOReader handles this well)
- **PDF**: Fixed-layout documents
- **CBZ**: Comic book archives
- **TXT**: Plain text files
- **RTF**: Rich text format
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
#### Automatic Book Matching
Books downloaded via OPDS are automatically matched:
- Uses SHA-256 hashes for precise matching
- Falls back to title/author matching
- Links to your existing Bookhoard library
- Progress syncs automatically
#### Collection Integration
Your Bookhoard collections appear in KOReader:
- Collection **"To Read"** → KOReader category
- Collection **"Science Fiction"** → Browseable section
- Custom collections → Preserved organization
### KOReader OPDS Settings
#### Update Interval
Configure how often KOReader checks for new books:
1. KOReader menu → Tools → OPDS
2. Set **Update Interval**: 5min, 15min, 1hr, manual
3. **Recommended**: 15min for balance
#### Download Location
Choose where to store downloaded books:
1. KOReader menu → File Browser
2. Set **Default Download Folder**
3. **Recommended**: `/mnt/us/Documents/` (Kindle) or `/mnt/onboard/Documents/` (Kobo)
#### Auto-Download
Automatically download new books from collections:
1. KOReader menu → Tools → OPDS
2. Enable **Auto-Download New Books**
3. Select collections to monitor
4. New books download automatically when connected to Wi-Fi
### OPDS Troubleshooting
#### Catalog Not Loading
**Problem**: Bookhoard catalog shows error or won't load
**Solutions**:
1. Verify device is connected to Wi-Fi
2. Check OPDS URL is correct in settings
3. Try accessing OPDS URL in your browser
4. Ensure Bookhoard server is running
5. Check Bookhoard device is approved
#### Download Fails
**Problem**: Book download starts but fails
**Solutions**:
1. Check Wi-Fi signal strength
2. Ensure sufficient storage on device
3. Try downloading a smaller book
4. Check Bookhoard has the book file
5. Review Bookhoard logs for errors
#### Book Opens But Progress Doesn't Sync
**Problem**: Downloaded book doesn't sync progress
**Solutions**:
1. Verify book is matched to Bookhoard library
2. Check device sync settings are enabled
3. Try manual sync from device
4. Ensure book exists in Bookhoard with same hash
5. Check Bookhoard Progress page
#### Slow Downloads
**Problem**: Books take too long to download
**Solutions**:
1. Stay close to Wi-Fi router
2. Use 5GHz Wi-Fi if available
3. Close other apps using bandwidth
4. Download smaller books first
5. Consider USB for large books (100MB+)
### Advanced OPDS Configuration
#### Custom User-Agent
Some OPDS catalogs require specific user agent:
```lua
-- In KOReader settings
OPDSUserAgent = "KOReader/2024.01"
```
#### Authentication Token
If Bookhoard requires token authentication:
1. Get token from Bookhoard device settings
2. Add to OPDS URL: `?token=YOUR_TOKEN`
3. KOReader includes token in all requests
#### Compression
Enable compression for faster downloads:
```lua
-- In KOReader settings
OPDSCompressionEnabled = true
```
### OPDS vs USB Transfer
| Feature | OPDS (Wireless) | USB Transfer |
| ----------------- | ----------------------------- | ----------------------- |
| **Convenience** | ⭐⭐⭐⭐⭐ No cable needed | ⭐⭐ Requires cable |
| **Speed** | ⭐⭐⭐ Fast (Wi-Fi dependent) | ⭐⭐⭐⭐⭐ Very fast |
| **Bulk Transfer** | ⭐⭐⭐ One at a time | ⭐⭐⭐⭐⭐ Many at once |
| **Progress Sync** | ⭐⭐⭐⭐⭐ Instant | ⭐⭐⭐⭐ After transfer |
| **Accessibility** | ⭐⭐⭐⭐⭐ Anywhere | ⭐⭐ At computer only |
| **Reliability** | ⭐⭐⭐⭐ Very good | ⭐⭐⭐⭐⭐ Excellent |
**Recommendation**: Use OPDS for daily reading (convenience), USB for bulk library transfers.
### OPDS Tips and Tricks
1. **Favorite Collections**: Pin frequently-used collections to home screen
2. **Batch Downloads**: Start multiple downloads before leaving Wi-Fi
3. **Download Queue**: Downloads continue in background while reading
4. **Storage Management**: Check free space before downloading large collections
5. **Network Speed**: Use 5GHz Wi-Fi for faster downloads if available
## Troubleshooting
### Pending Registration Never Appears
**Problem**: You entered the Server URL, but no pending registration shows in Bookhoard
**Solutions**:
1. Verify the Server URL is correct (no trailing path — just the base address)
2. Make sure KOReader is connected to Wi-Fi
3. Check the Bookhoard server is reachable from the device's network
4. Registrations expire after 5 minutes — re-run the sync and approve quickly
### Connection Refused
**Problem**: "Connection refused" error on the device
**Problem**: "Connection refused" error
**Solutions**:
- Verify Bookhoard is running
- Check the server address and port (default `8765`)
- Ensure the device is on the same Wi-Fi network as the server
- Use the server's LAN IP instead of `localhost`
- Verify Bookhoard is running on your computer
- Check the server URL and port (8765)
- Ensure device is on same Wi-Fi network
- Try using your computer's IP address instead of "localhost"
### Sync Not Working After Approval
### Authentication Failed
**Problem**: Device shows as approved but changes don't appear in Bookhoard
**Problem**: "Authentication failed" error
**Solutions**:
- Trigger a manual sync from the plugin menu
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
- Verify the book appears as an unlinked book for the device and link it if needed
- Check Bookhoard server logs for errors
- Verify username and password
- Check your account is active and not locked
- Try logging in to Bookhoard web interface first
- Reset password if needed
### Sync Not Working
**Problem**: Changes not appearing in Bookhoard
**Solutions**:
- Enable debug logging in KOReader
- Check Bookhoard Device Management page for errors
- Verify sync is enabled in KOReader settings
- Try manual sync to trigger immediate update
- Check Bookhoard logs for sync errors
### Conflicts Detected
**Problem**: Sync conflicts when reading the same book on multiple devices
**Problem**: Sync conflicts when reading on multiple devices
**Solutions**:
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
2. Review the progress reported by each device
1. Go to Bookhoard **Conflicts** page
2. Review conflicting progress from each device
3. Choose which device's progress to keep
4. Set auto-resolution preference for future conflicts
### Large Files Not Syncing
**Problem**: Large annotations or highlights fail to sync
**Solutions**:
- Check Bookhoard sync queue for stuck items
- Increase sync timeout in KOReader settings
- Break up large highlights into smaller segments
- Verify network bandwidth is sufficient
## Security Best Practices
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
3. **Device Authorization**: Only approve pending registrations you initiated
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
1. **Use HTTPS**: If deploying Bookhoard publicly, configure SSL/TLS
2. **Strong Password**: Use a secure password for your Bookhoard account
3. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
4. **Device Authorization**: Only approve devices you recognize
5. **Regular Updates**: Keep KOReader updated to the latest version
## Additional Resources
- [KOReader Documentation](https://github.com/koreader/koreader)
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
- [Bookhoard Universal Sync Guide](../sync-guide.md)
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
- [Kobo Setup Guide](kobo-setup.md)
## Support
If you encounter issues:
1. Check the troubleshooting section above
2. Enable debug logging and review KOReader logs
3. Check Bookhoard sync queue and device management pages
4. Open an issue on the Bookhoard GitHub repository
---
**Last Updated**: August 2026
**Bookhoard Version**: 1.0
**Last Updated**: 2026-01-31
**Bookhoard Version**: 1.0
**KOReader Version**: 2024.01+
+30 -28
View File
@@ -1,58 +1,60 @@
# Saving Custom Filters
## Saving Custom Filters
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
The bookshelf page allows you to save custom filter presets for quick access.
## Bookshelf Toolbar
The All Books page has a toolbar with:
- A **search input** for quick text searches
- A **sort** dropdown (title, author, date added, page count)
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
- **Save**, **Load**, and **Clear** buttons for filter presets
## How to Save a Filter
### How to Save a Filter
1. Navigate to the **All Books** page
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
3. Click the **Save** button in the toolbar
2. Set your desired filters (genre, author, series, etc.)
3. Click the **💾 Save Filter** button
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
5. Click **Save**
## Loading Saved Filters
### Loading Saved Filters
1. Click the **Load** button to open the **Saved Filters** dropdown
2. Click a filter's name to apply it
3. The filter values are applied instantly, without a page reload
After saving filters, you can quickly load them from the saved filters dropdown:
## Managing Saved Filters
1. Click the **📋 Saved Filters** button (next to the Save Filter button)
2. Select a filter from the dropdown list
3. The filter values are automatically applied to the form
4. Your books are instantly filtered to show matching results
**Delete a filter:**
**Tips:**
- Saved filters appear in the dropdown with their names
- Hover over a filter to see a delete button (🗑️)
- Click a filter name to apply it instantly
- Filters are applied without page reload (instant feedback)
1. Click the **Load** button to open the **Saved Filters** dropdown
2. Click the trash icon next to the filter you want to remove
3. Confirm deletion
### Managing Saved Filters
**View Saved Filters:**
- Saved filters are displayed in the dropdown
- Each filter shows its name (e.g., "My Sci-Fi Books")
**Delete a Filter:**
1. Click the **📋 Saved Filters** button
2. Hover over the filter you want to delete
3. Click the **🗑️** delete button
4. Confirm deletion
5. The filter is removed from your list
**Filter Privacy:**
Saved filters are **private to your account**. Other users cannot see or modify your filters.
## Common Use Cases
### Common Use Cases
**Reading by Genre:**
1. Filter by tag: "Science Fiction"
1. Filter by genre: "Science Fiction"
2. Save as "Sci-Fi Books"
3. Quickly access all your sci-fi collection anytime
**Author Collections:**
1. Filter by author: "Isaac Asimov"
2. Save as "Asimov Books"
3. Switch between different author collections instantly
**Series Tracking:**
1. Filter by series: "Foundation"
2. Save as "Foundation Series"
3. Track your progress through a series
+9 -9
View File
@@ -6,10 +6,10 @@ Your profile contains your account information and preferences.
### How to Update
1. Click on your **username** at the bottom of the sidebar to expand the account menu
2. Select **Profile**
1. Click on your **username** (top-right)
2. Select **Profile** from the dropdown
3. Edit any fields in the "Account Information" section
4. Click **Save Changes**
4. Click **Update Profile**
5. Changes take effect immediately
### Fields You Can Update
@@ -65,7 +65,7 @@ When you delete your account:
1. Go to **Profile** page
2. Scroll to "Danger Zone" (bottom of page)
3. Click **Remove My Account**
4. Confirm the deletion prompt
4. Confirm by clicking "OK" in the popup
**Note:** If you're the last admin, you cannot delete your account for security reasons.
@@ -75,12 +75,10 @@ Personalize your reading experience with different color themes.
### Quick Theme Switch
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
2. Select a theme from the list; your active theme is marked with a checkmark
1. Click the **paintbrush icon** (top-right, next to your username)
2. Select a theme from the dropdown
3. Changes apply instantly
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
### Available Themes
- **Tokyo Night** (default) - Blue/purple accents
@@ -90,7 +88,9 @@ For the full theme list and bookshelf background (wood) options, see [Themes and
- **Monokai** - Classic vibrant colors
- **One Dark Pro** - Atom editor inspired
- **Material Dark** - Google Material Design
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
- **Wood Light** - Light wood texture
- **Wood Dark** - Dark wood texture
- **Wood Mahogany** - Reddish-brown wood
## For Admin Users
+4 -5
View File
@@ -13,11 +13,10 @@ Tags are keywords or categories assigned to books, such as:
### Filtering by Tags
1. Navigate to the **All Books** page
2. Click **Filters** in the toolbar to open the filter drawer
3. Use the **Tags** filter input
4. Start typing to see autocomplete suggestions
5. Select a tag or press Enter, then click **Apply Filters**
1. Navigate to the **Bookshelf** page
2. Use the **Tags** filter input
3. Start typing to see autocomplete suggestions
4. Select a tag or press Enter to filter
**Example:** Typing "Sci" will suggest "Science Fiction"
+40 -43
View File
@@ -21,7 +21,7 @@
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap
📱 **Multi-Platform** - Works with web browsers, KOReader, Kobo devices, and mobile apps
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
@@ -37,24 +37,19 @@
### Currently Supported ✅
| Platform | Status | Sync Method | Notes |
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
| Platform | Status | Sync Method | Notes |
| ---------------- | ------------------ | --------------------------- | ------------------------------ |
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
| **KOReader** | ✅ Fully Supported | Wi-Fi (Calibre-compatible) | Kindle, Kobo, PocketBook, etc. |
| **Kobo Devices** | ✅ Fully Supported | Wi-Fi (Kobo API-compatible) | Clara, Libra, Sage, etc. |
### Coming Soon 🚧
| Platform | Status |
| --------------------- | ------------------------------------------------------------- |
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
| **Mobile Apps** | Android/iOS apps coming later |
### On the Roadmap 🔭
| Platform | Status |
| --------------------- | ---------------------------------------- |
| **Kindle Devices** | Under consideration (no date yet) |
| **Remarkable Tablet** | Under consideration (no date yet) |
| Platform | Expected Release |
| --------------------- | ---------------- |
| **Mobile Apps** | Q2 2026 |
| **Kindle Devices** | Q3 2026 |
| **Remarkable Tablet** | Q4 2026 |
---
@@ -79,21 +74,22 @@
For detailed device configuration instructions, see the appropriate setup guide:
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Kobo e-reader configuration
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration
### Quick Overview
**Registration Process** (KOReader):
**Registration Process**:
1. Install the Bookhoard plugin and enter your server URL in KOReader
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
3. That's it — sync starts automatically once approved
1. Register device in Bookhoard web interface (Settings → Devices)
2. Approve device via QR code or approval URL
3. Configure sync settings on your device
4. Start reading - progress syncs automatically!
**Device Management**:
```
Devices page (sidebar navigation)
Settings → Devices
```
You can:
@@ -128,7 +124,7 @@ Sometimes a book on your device can't be automatically matched to your library.
### Viewing Unlinked Books
```
Devices page select device → unlinked books
Settings → Devices → Select Device → View Unlinked Books
```
### Resolving Unlinked Books
@@ -288,7 +284,7 @@ This ensures your highlights work across all devices, even with different page c
**Solutions**:
1. Check device is online: Devices page (sidebar navigation)
1. Check device is online: `Settings → Devices`
2. Verify sync is enabled for the device
3. Check sync URL is correct
4. Ensure device has network connection
@@ -322,7 +318,7 @@ This ensures your highlights work across all devices, even with different page c
**Solutions**:
1. Open the book's detail page and click **Sync Progress**, or go to the Conflicts page (`/conflicts`)
1. Go to `Settings → Conflicts`
2. Review both device progress
3. Choose which device's progress to keep
4. Or choose "Merge" (keeps furthest progress)
@@ -335,7 +331,7 @@ This ensures your highlights work across all devices, even with different page c
1. Switch to checkpoint mode
2. Increase sync interval
3. Sync less frequently
3. Use Wi-Fi instead of cellular (for mobile)
---
@@ -345,7 +341,7 @@ This ensures your highlights work across all devices, even with different page c
**DO**:
- Use checkpoint mode when on slow connections
- Use checkpoint mode when on cellular data
- Keep device firmware updated
- Use Wi-Fi when available
- Approve only devices you own
@@ -373,7 +369,7 @@ This ensures your highlights work across all devices, even with different page c
- **Primary Device**: KOReader on e-reader
- **Secondary Device**: Web browser (work/home)
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
- **Mobile Device**: Phone app (commute)
**Sync Strategy**:
@@ -397,7 +393,7 @@ This ensures your highlights work across all devices, even with different page c
**Manual Resolution**:
```
Book detail → Sync Progresschoose winner
Settings → Conflicts → Select conflictChoose winner
```
**Options**:
@@ -412,7 +408,7 @@ Book detail → Sync Progress → choose winner
**View Queue Status**:
```
Devices page → sync queue section
Settings → Devices → Select Device → View Queue
```
**Queue Stats**:
@@ -440,7 +436,7 @@ Devices page → sync queue section
**View History**:
```
Progress page (sidebar navigation), or the book's detail page
Book → Reading History
```
**Privacy**:
@@ -507,8 +503,9 @@ Progress page (sidebar navigation), or the book's detail page
### For Better Battery Life
1. **Checkpoint mode** - Fewer sync requests
2. **Increase sync interval** - Fewer updates
3. **Close when not reading** - Reduces background activity
2. **Wi-Fi only** - Disable cellular
3. **Increase sync interval** - Fewer updates
4. **Close when not reading** - Reduces background activity
---
@@ -526,7 +523,7 @@ A: No, devices are tied to individual accounts for security.
A: All sync data for that book is removed from the server.
**Q: Can I export my reading data?**
A: Reading data isn't exportable from the UI yet — it's accessible via the API.
A: Yes! Settings → Export → Download sync data.
**Q: Does sync work over the internet?**
A: Yes, if your server is publicly accessible with HTTPS.
@@ -540,7 +537,7 @@ A: Approximately 1KB per page turn, 50KB per annotation.
A: Uses percentage and EPUB CFI for universal positioning.
**Q: Can I sync with Calibre anymore?**
A: Bookhoard's KOReader sync uses a dedicated plugin (server-side approval, device tokens) — no Calibre involvement required.
A: Yes! KOReader sync is Calibre-compatible.
**Q: What if I lose my device?**
A: Revoke it in settings and register a new one.
@@ -574,18 +571,18 @@ A: Yes, HTTPS/TLS 1.3 for all sync traffic.
## Changelog
### Version 1.0.x (2026)
### Version 1.0.0 (January 2026)
- ✅ Initial release
- ✅ KOReader sync support
- ✅ Kobo device support
- ✅ Web sync support
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
- ✅ Conflict resolution
- ✅ Offline queue
- ✅ Real-time WebSocket sync
- 🚧 Native Kobo sync (coming soon)
- 🚧 Mobile apps (coming later)
---
**Last Updated**: August 2026
**Version**: 1.0
**License**: AGPL-3.0
**Last Updated**: January 31, 2026
**Version**: 1.0.0
**License**: MIT
+6 -8
View File
@@ -18,12 +18,10 @@ Bookhoard includes multiple color themes to suit your preferences:
### Changing Your Theme
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
2. Pick a theme from the list — each option shows its color swatch, and your active theme is marked with a checkmark
1. Click the theme icon (palette) in the header
2. Select your preferred color theme
3. Your choice is saved automatically and synced across devices
On small screens, open the sidebar with the menu button in the top bar first.
## Wood Paneling
Wood paneling adds texture to your dashboard bookshelf background, giving it a classic bookshelf feel.
@@ -37,10 +35,10 @@ Wood paneling adds texture to your dashboard bookshelf background, giving it a c
### Applying Wood Paneling
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
2. Scroll to the **Bookshelf** section below the theme list
3. Select your preferred wood texture (each option shows a texture swatch; **None** is the default)
4. Texture is applied to the dashboard bookshelf background
1. Click the theme icon (palette) in the header
2. Scroll to "Bookshelf Background" section
3. Select your preferred wood texture
4. Texture is applied to dashboard bookshelf only
**Note:** Wood paneling is a browser preference and is not synced across devices.
+9 -7
View File
@@ -6,15 +6,17 @@ Welcome to the Bookhoard user documentation. This section contains guides for us
Learn how to configure your e-reader devices to sync with Bookhoard:
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
- Installation on Kindle/Kobo/PocketBook
- Plugin setup with server-side device approval
- Progress, bookmark, highlight, and note sync
- OPDS catalog access
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Complete guide for Kobo e-readers
- Device registration
- Sync configuration
- OPDS wireless book delivery
- Troubleshooting
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
- In the meantime, KOReader works great on Kobo hardware
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
- Installation on Kindle/Kobo/PocketBook
- Sync setup
- OPDS catalog access
- Troubleshooting
## 🔄 Sync Configuration
+1 -1
View File
@@ -29,7 +29,6 @@ require (
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
)
@@ -58,6 +57,7 @@ require (
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/xyproto/randomstring v1.2.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.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
+20 -9
View File
@@ -45,19 +45,30 @@ func (c *Config) DatabaseURL() string {
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
}
// SystemConfigGetter returns the value for a system config key, or an error.
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
// GetBaseURL returns the base URL from system configuration database, or empty
// string if not set. The getter abstraction avoids importing the database package.
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
val, err := getter(ctx, "base_url")
if err == nil && val != "" {
return val
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
func GetBaseURL(ctx context.Context, db interface{}) string {
// Try to get from database first
type SystemConfigQuerier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
}
if querier, ok := db.(SystemConfigQuerier); ok {
config, err := querier.GetSystemConfig(ctx, "base_url")
if err == nil && config.Value != "" {
return config.Value
}
}
// Fallback: return empty string - caller should use their own fallback
return ""
}
// SystemConfigRow represents a system configuration row
type SystemConfigRow struct {
Key string
Value string
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
+36 -73
View File
@@ -92,17 +92,6 @@ type DictionaryCache struct {
AccessedAt pgtype.Timestamptz `db:"accessed_at" json:"accessed_at"`
}
type HashConflicts struct {
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FileSha256 string `db:"file_sha256" json:"file_sha256"`
Status string `db:"status" json:"status"`
Resolution pgtype.Text `db:"resolution" json:"resolution"`
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
}
type KoboEntitlements struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
@@ -166,55 +155,40 @@ 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"`
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"`
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"`
}
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"`
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"`
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"`
}
type MediaItemFormats struct {
@@ -330,11 +304,6 @@ 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 {
@@ -411,7 +380,6 @@ 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"`
@@ -495,16 +463,11 @@ type SystemConfig struct {
}
type SystemSettings struct {
ID pgtype.UUID `db:"id" json:"id"`
SettingKey string `db:"setting_key" json:"setting_key"`
SettingValue string `db:"setting_value" json:"setting_value"`
Description pgtype.Text `db:"description" json:"description"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
MinValue pgtype.Text `db:"min_value" json:"min_value"`
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
Category pgtype.Text `db:"category" json:"category"`
ID pgtype.UUID `db:"id" json:"id"`
SettingKey string `db:"setting_key" json:"setting_key"`
SettingValue string `db:"setting_value" json:"setting_value"`
Description pgtype.Text `db:"description" json:"description"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type UnlinkedBooks struct {
+2 -77
View File
@@ -26,15 +26,13 @@ type Querier interface {
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) 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,17 +51,11 @@ type Querier interface {
// Create device shelf mapping
CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error)
CreateDictionaryEntry(ctx context.Context, arg CreateDictionaryEntryParams) (DictionaryCache, error)
// HASH CONFLICTS QUERIES
// Record a pending hash conflict (no-op if the group is already tracked, so
// resolved groups stay resolved and are never re-flagged)
CreateHashConflict(ctx context.Context, arg CreateHashConflictParams) error
// 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
@@ -71,7 +63,6 @@ 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
@@ -133,17 +124,10 @@ type Querier interface {
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
// Find content-duplicate groups (same library + SHA-256, more than one row)
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, 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)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection
@@ -190,7 +174,6 @@ type Querier interface {
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
@@ -213,17 +196,8 @@ 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)
@@ -239,21 +213,13 @@ type Querier interface {
GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error)
// Get media item by SHA-256 hash
GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error)
// Get media item by SHA-256 hash within a specific library (content dedup)
GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMediaItemBySHA256AndLibraryParams) (MediaItems, error)
// Get media item format by SHA-256
GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error)
// Get media item format by type
GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error)
// Get media item formats
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
// Per-item user-data counts, used when choosing which duplicate copy to keep
GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, 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)
@@ -277,7 +243,7 @@ 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)
GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]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)
@@ -290,9 +256,7 @@ type Querier interface {
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
// System Settings queries
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, 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
@@ -316,8 +280,6 @@ 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)
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, 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)
@@ -327,25 +289,12 @@ type Querier interface {
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)
// ============================================
// ANNOTATION HISTORY (deleted-annotation archive)
// ============================================
// Lists every currently-tombstoned annotation for a book regardless of the
// tombstone TTL: this backs the book page's "recently deleted" history where
// users can restore or permanently remove entries. Rows whose tombstones have
// been purged by the daily maintenance sweep no longer exist at all.
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
// List media items that have no stored SHA-256 (imported before hashing existed)
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
@@ -353,14 +302,6 @@ 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
PurgeMediaBookmarkByID(ctx context.Context, arg PurgeMediaBookmarkByIDParams) (int64, error)
// Permanent removal from the history (distinct from the TTL-driven purge,
// which is maintenance). Scoped to the owning user and book.
PurgeMediaHighlightByID(ctx context.Context, arg PurgeMediaHighlightByIDParams) (int64, error)
PurgeMediaNoteByID(ctx context.Context, arg PurgeMediaNoteByIDParams) (int64, 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
@@ -368,17 +309,11 @@ type Querier interface {
// Remove book from collection
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
// Resolve unlinked book
ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error)
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
RevokeDevice(ctx context.Context, id pgtype.UUID) error
// Revoke OPDS token
@@ -397,12 +332,6 @@ type Querier interface {
// 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)
@@ -426,9 +355,7 @@ 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
@@ -446,7 +373,6 @@ 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)
@@ -465,7 +391,6 @@ type Querier interface {
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
}
var _ Querier = (*Queries)(nil)
File diff suppressed because it is too large Load Diff
+34 -499
View File
@@ -137,19 +137,10 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true
ORDER BY l.created_at ASC;
-- name: GetVisibleLibraryMediaCounts :many
SELECT l.id, COUNT(mi.id) as media_count
FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN media_items mi ON mi.library_id = l.id
WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id;
-- 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, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING *;
-- name: GetMediaItem :one
@@ -355,9 +346,6 @@ 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;
@@ -374,29 +362,9 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
-- name: UpdateSystemSetting :exec
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
-- name: UpsertSystemSetting :one
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (setting_key) DO UPDATE
SET setting_value = EXCLUDED.setting_value,
description = EXCLUDED.description,
setting_type = EXCLUDED.setting_type,
min_value = EXCLUDED.min_value,
max_value = EXCLUDED.max_value,
requires_restart = EXCLUDED.requires_restart,
category = EXCLUDED.category,
updated_at = NOW()
RETURNING *;
-- name: GetSystemSettingFull :one
SELECT * FROM system_settings WHERE setting_key = $1;
-- name: GetAllSystemSettings :many
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
-- name: GetAllSystemSettingsFull :many
SELECT * FROM system_settings ORDER BY category, setting_key;
-- name: CreateMediaRating :one
INSERT INTO media_ratings (media_item_id, user_id, rating)
VALUES ($1, $2, $3)
@@ -725,7 +693,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 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
-- name: UpdateMediaNote :one
UPDATE media_notes SET
@@ -748,7 +716,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 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
-- name: UpdateMediaHighlight :one
UPDATE media_highlights SET
@@ -764,350 +732,6 @@ 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(),
deleted = FALSE,
deleted_at = NULL
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(),
deleted = FALSE,
deleted_at = NULL
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,
deleted = FALSE,
deleted_at = NULL
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,
mh.start_position,
mh.end_position,
mh.epubcfi_start,
mh.epubcfi_end
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,
mn.position as start_position,
NULL as end_position,
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 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,
mb.position as start_position,
NULL as end_position,
mb.cfi_position as epubcfi_start,
NULL as epubcfi_end
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;
-- ============================================
-- ANNOTATION HISTORY (deleted-annotation archive)
-- ============================================
-- Lists every currently-tombstoned annotation for a book regardless of the
-- tombstone TTL: this backs the book page's "recently deleted" history where
-- users can restore or permanently remove entries. Rows whose tombstones have
-- been purged by the daily maintenance sweep no longer exist at all.
-- name: ListDeletedAnnotationsForBook :many
SELECT
mh.id,
mh.dedup_key,
'highlight' as annotation_type,
mh.selection_text as display_text,
mh.note_text as secondary_text,
mh.color,
mh.deleted_at,
mh.created_at
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE
UNION ALL
SELECT
mn.id,
mn.dedup_key,
'note' as annotation_type,
mn.content as display_text,
NULL::text as secondary_text,
NULL::text as color,
mn.deleted_at,
mn.created_at
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE
UNION ALL
SELECT
mb.id,
mb.dedup_key,
'bookmark' as annotation_type,
mb.title as display_text,
mb.notes as secondary_text,
NULL::text as color,
mb.deleted_at,
mb.created_at
FROM media_bookmarks mb
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE
ORDER BY deleted_at DESC;
-- name: RestoreMediaHighlightByID :execrows
UPDATE media_highlights SET
deleted = FALSE,
deleted_at = NULL,
last_modified_at = NOW()
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
-- name: RestoreMediaNoteByID :execrows
UPDATE media_notes SET
deleted = FALSE,
deleted_at = NULL,
last_modified_at = NOW()
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
-- name: RestoreMediaBookmarkByID :execrows
UPDATE media_bookmarks SET
deleted = FALSE,
deleted_at = NULL,
last_modified_at = NOW()
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
-- Permanent removal from the history (distinct from the TTL-driven purge,
-- which is maintenance). Scoped to the owning user and book.
-- name: PurgeMediaHighlightByID :execrows
DELETE FROM media_highlights
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
-- name: PurgeMediaNoteByID :execrows
DELETE FROM media_notes
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
-- name: PurgeMediaBookmarkByID :execrows
DELETE FROM media_bookmarks
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
-- Refresh Tokens queries
-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (user_id, token, expires_at)
@@ -1127,7 +751,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
-- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
-- ============================================
-- FORMAT DETECTION & PROGRESS
@@ -1169,7 +793,6 @@ SELECT
rp.percentage,
rp.character_offset,
rp.epubcfi,
rp.context_text,
rp.chapter,
rp.chapter_progress,
rp.viewport_x,
@@ -1202,7 +825,6 @@ INSERT INTO reading_progress (
percentage,
character_offset,
epubcfi,
context_text,
chapter,
chapter_progress,
viewport_x,
@@ -1220,14 +842,13 @@ INSERT INTO reading_progress (
last_read_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, 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,
@@ -1499,11 +1120,6 @@ 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;
@@ -1546,15 +1162,6 @@ 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
-- ============================================
@@ -1603,7 +1210,7 @@ SELECT
mh.epubcfi_start,
mh.epubcfi_end
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
WHERE mh.media_item_id = $1 AND mh.user_id = $2
UNION ALL
SELECT
mn.id,
@@ -1619,7 +1226,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 AND COALESCE(mn.deleted, FALSE) = FALSE
WHERE mn.media_item_id = $1 AND mn.user_id = $2
ORDER BY created_at DESC;
-- name: UpdateDeviceSyncTimestamp :one
@@ -1807,70 +1414,6 @@ RETURNING *;
-- name: GetMediaItemBySHA256 :one
SELECT * FROM media_items WHERE file_sha256 = $1;
-- Get media item by SHA-256 hash within a specific library (content dedup)
-- name: GetMediaItemBySHA256AndLibrary :one
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
-- name: ListMediaItemsBySHA256AndLibrary :many
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
-- List media items that have no stored SHA-256 (imported before hashing existed)
-- name: ListMediaItemsMissingHash :many
SELECT * FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at;
-- Find content-duplicate groups (same library + SHA-256, more than one row)
-- name: FindHashConflictGroups :many
SELECT library_id, file_sha256, COUNT(*) AS dup_count
FROM media_items
WHERE file_sha256 IS NOT NULL
GROUP BY library_id, file_sha256
HAVING COUNT(*) > 1;
-- Per-item user-data counts, used when choosing which duplicate copy to keep
-- name: GetMediaItemUsageCounts :one
SELECT
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count;
-- HASH CONFLICTS QUERIES
-- Record a pending hash conflict (no-op if the group is already tracked, so
-- resolved groups stay resolved and are never re-flagged)
-- name: CreateHashConflict :exec
INSERT INTO hash_conflicts (library_id, file_sha256)
VALUES ($1, $2)
ON CONFLICT (library_id, file_sha256) DO NOTHING;
-- name: ListPendingHashConflicts :many
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
l.name AS library_name,
COUNT(mi.id) AS item_count
FROM hash_conflicts hc
JOIN libraries l ON l.id = hc.library_id
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
WHERE hc.status = 'pending'
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
ORDER BY hc.created_at;
-- name: GetHashConflict :one
SELECT * FROM hash_conflicts WHERE id = $1;
-- name: ResolveHashConflict :exec
UPDATE hash_conflicts
SET status = 'resolved',
resolution = $2,
resolved_by = $3,
resolved_at = NOW()
WHERE id = $1;
-- Re-parent all child rows of p_source onto p_target (defined in schema.sql)
-- name: ReparentMediaItemChildren :exec
SELECT reparent_media_item_children($1::uuid, $2::uuid);
-- Get media item by OPF identifier
-- name: GetMediaItemByOPFIdentifier :one
SELECT * FROM media_items WHERE opf_identifier = $1;
@@ -1918,11 +1461,6 @@ ORDER BY confidence_score DESC;
-- name: CreateMediaItemFormat :one
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (media_item_id, format_type) DO UPDATE SET
file_path = EXCLUDED.file_path,
file_sha256 = EXCLUDED.file_sha256,
file_size_bytes = EXCLUDED.file_size_bytes,
mime_type = EXCLUDED.mime_type
RETURNING *;
-- Get media item formats
@@ -2292,7 +1830,7 @@ LIMIT $1 OFFSET $2;
-- Dashboard preferences queries
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
WHERE user_id = $1 AND library_id = $2;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
@@ -2356,57 +1894,57 @@ 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 = sqlc.narg('user_id')
WHERE user_id = $2
AND percentage > 0
AND percentage < 1
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
WHERE mi.library_id = $1
ORDER BY rp.last_read_at DESC
LIMIT sqlc.narg('limit');
LIMIT $3;
-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
WHERE mi.library_id = $1
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
LIMIT sqlc.narg('limit');
LIMIT $2;
-- 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 = sqlc.narg('user_id')
WHERE user_id = $2
AND percentage >= 1
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
WHERE mi.library_id = $1
ORDER BY rp.last_read_at DESC
LIMIT sqlc.narg('limit');
LIMIT $3;
-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
WHERE mi.library_id = $1
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id
AND rp.user_id = sqlc.narg('user_id')
AND rp.user_id = $2
AND rp.percentage > 0
)
ORDER BY mi.created_at DESC
LIMIT sqlc.narg('limit');
LIMIT $3;
-- 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 = sqlc.narg('collection_id')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
WHERE ci.collection_id = $1
AND mi.library_id = $2
ORDER BY ci.added_at DESC
LIMIT sqlc.narg('limit');
LIMIT $3;
-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC;
-- name: GetDistinctSeries :many
@@ -2414,26 +1952,26 @@ 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 != ''
WHERE library_id = $1 AND series IS NOT NULL AND series != ''
GROUP BY series
ORDER BY MAX(created_at) DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
LIMIT $2 OFFSET $3;
-- 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 != '';
WHERE library_id = $1 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 != ''
WHERE library_id = $1 AND series = $2 AND cover_image_path IS NOT NULL AND cover_image_path != ''
ORDER BY series_number ASC NULLS LAST
LIMIT sqlc.narg('limit');
LIMIT $3;
-- name: GetSeriesBooks :many
SELECT * FROM media_items
WHERE series = sqlc.narg('series')
WHERE library_id = $1 AND series = $2
ORDER BY series_number ASC NULLS LAST;
-- name: GetContinueSeriesItems :many
@@ -2443,10 +1981,10 @@ WITH user_series_progress AS (
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')
WHERE rp.user_id = $2
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)
AND mi.library_id = $1
GROUP BY mi.series
),
next_books AS (
@@ -2454,13 +1992,13 @@ next_books AS (
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)
WHERE mi.library_id = $1
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');
LIMIT $3;
-- name: GetSavedFilters :many
SELECT * FROM saved_filters
@@ -2559,12 +2097,9 @@ RETURNING *;
-- name: GetMediaBookmarks :many
SELECT * FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
WHERE media_item_id = $1 AND user_id = $2
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)
@@ -2580,7 +2115,7 @@ SET
title = $2,
notes = $3,
position = $4,
last_modified_at = NOW()
updated_at = NOW()
WHERE id = $1 AND user_id = $5
RETURNING *;
-342
View File
@@ -1,342 +0,0 @@
package database
// SettingsRegistry provides a typed, cached view over the system_settings table.
// It is the single source of truth for tunable runtime values that used to be
// hardcoded as Go literals.
//
// Consumers call the domain-specific getters (SessionDuration, OpdsPageSize,
// etc.) which read from an in-memory cache. The cache is populated by Load at
// startup and refreshed by Reload whenever a setting is written. Getters always
// fall back to a compiled-in default if the DB value is missing or unparsable,
// so a corrupt or deleted row can never break the app.
//
// SettingsRegistry lives in the database package (rather than its own package)
// so that every consumer already imports database and does not need to take on
// a new package import.
import (
"context"
"log"
"strconv"
"sync"
"time"
)
// SettingType enumerates the value types stored in system_settings.setting_type.
const (
SettingTypeInt = "int"
SettingTypeBool = "bool"
SettingTypeString = "string"
SettingTypeStringList = "string_list"
)
// SecondsPerDay / SecondsPerHour are conversion helpers used by defaults.
const (
SecondsPerMinute = 60
SecondsPerHour = 3600
SecondsPerDay = 86400
)
// SettingDefault holds the fallback value for a key. These mirror the literals that
// were previously hardcoded in the source so an empty/corrupt DB row preserves
// prior behavior exactly.
type SettingDefault struct {
Key string
Value string
Type string
Min string
Max string
RequiresRestart bool
Category string
Group string
Description string
}
// SettingDefaults is the source of truth for fallback values and metadata. New keys
// must be added here AND seeded in database/schema/schema.sql. Entries are ordered
// by (RequiresRestart, Group) so the admin UI renders coherent sub-sections.
var SettingDefaults = []SettingDefault{
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
{Key: "password_require_upper", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one uppercase letter (A-Z)"},
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
{Key: "device_rate_progress_per_min", Value: "120", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device progress requests per minute"},
{Key: "device_rate_metadata_per_min", Value: "30", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device metadata requests per minute"},
{Key: "annotation_tombstone_ttl_days", Value: "30", Type: SettingTypeInt, Min: "1", Max: "3650", Category: "sync", Group: "Annotation Retention", Description: "How long deleted annotations are kept before purge"},
{Key: "conversion_cache_ttl_hours", Value: "24", Type: SettingTypeInt, Min: "1", Max: "720", Category: "performance", Group: "Conversion Cache", Description: "How long converted (kepub) files are cached"},
{Key: "auth_rate_limit_per_min", Value: "10", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "security", Group: "Auth Rate Limiting", Description: "Global auth API rate limit (requests per minute)"},
{Key: "login_max_attempts", Value: "5", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Failed login attempts before lockout"},
{Key: "login_lockout_minutes", Value: "15", Type: SettingTypeInt, Min: "1", Max: "10080", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Lockout duration after too many failed logins"},
{Key: "sync_queue_interval_seconds", Value: "5", Type: SettingTypeInt, Min: "1", Max: "3600", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "How often the sync queue flushes"},
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
}
// defaultBy indexes SettingDefaults by key for O(1) lookup.
var defaultBy = func() map[string]SettingDefault {
m := make(map[string]SettingDefault, len(SettingDefaults))
for _, d := range SettingDefaults {
m[d.Key] = d
}
return m
}()
// Registry caches system_settings values in memory. The zero value is not
// usable; construct with New.
type SettingsRegistry struct {
q *Queries
mu sync.RWMutex
values map[string]string
loadedAt time.Time
}
// New returns a Registry backed by the given queries. The cache is empty
// until Load is called.
func NewSettingsRegistry(q *Queries) *SettingsRegistry {
return &SettingsRegistry{q: q, values: make(map[string]string)}
}
// Load populates the cache from the database. Missing rows fall back to the
// compiled defaults. Safe to call multiple times.
func (r *SettingsRegistry) Load(ctx context.Context) error {
rows, err := r.q.GetAllSystemSettings(ctx)
if err != nil {
return err
}
fresh := make(map[string]string, len(SettingDefaults))
for _, d := range SettingDefaults {
fresh[d.Key] = d.Value
}
for _, row := range rows {
if _, ok := fresh[row.SettingKey]; ok {
fresh[row.SettingKey] = row.SettingValue
}
}
r.mu.Lock()
r.values = fresh
r.loadedAt = time.Now()
r.mu.Unlock()
return nil
}
// Reload refreshes the cache from the database. Should be called after any
// setting write. On error the cache is left untouched and the error is logged.
func (r *SettingsRegistry) Reload(ctx context.Context) {
if err := r.Load(ctx); err != nil {
log.Printf("settings: reload failed: %v", err)
}
}
// raw returns the cached string value for a key (or the default), clamped to
// [min, max] for int-typed keys.
func (r *SettingsRegistry) raw(key string) string {
r.mu.RLock()
v, ok := r.values[key]
r.mu.RUnlock()
if !ok || v == "" {
v = defaultBy[key].Value
}
return v
}
func (r *SettingsRegistry) getInt(key string) int {
d := defaultBy[key]
v := r.raw(key)
n, err := strconv.Atoi(v)
if err != nil {
n, _ = strconv.Atoi(d.Value)
}
if d.Min != "" {
if mn, err := strconv.Atoi(d.Min); err == nil && n < mn {
n = mn
}
}
if d.Max != "" {
if mx, err := strconv.Atoi(d.Max); err == nil && n > mx {
n = mx
}
}
return n
}
func (r *SettingsRegistry) getBool(key string) bool {
v := r.raw(key)
b, err := strconv.ParseBool(v)
if err != nil {
b, _ = strconv.ParseBool(defaultBy[key].Value)
}
return b
}
// ---- Domain-specific getters (call sites use these) ----
// ScanPollInterval is how often the scanner polls, as a duration.
func (r *SettingsRegistry) ScanPollInterval() time.Duration {
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
}
// AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
// DefaultTimezone returns the configured default timezone name.
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
// SessionDuration is how long a login session / refresh token stays valid.
func (r *SettingsRegistry) SessionDuration() time.Duration {
return time.Duration(r.getInt("session_duration_seconds")) * time.Second
}
// PasswordMinLength is the minimum password length.
func (r *SettingsRegistry) PasswordMinLength() int { return r.getInt("password_min_length") }
// PasswordRules bundles the active complexity requirements.
type PasswordRules struct {
MinLength int
Upper bool
Lower bool
Number bool
Special bool
}
// PasswordRules returns the active password complexity configuration.
func (r *SettingsRegistry) PasswordRules() PasswordRules {
return PasswordRules{
MinLength: r.PasswordMinLength(),
Upper: r.getBool("password_require_upper"),
Lower: r.getBool("password_require_lower"),
Number: r.getBool("password_require_number"),
Special: r.getBool("password_require_special"),
}
}
// AuthRateLimit is the global auth endpoint rate limit (requests/minute). Read
// once at startup.
func (r *SettingsRegistry) AuthRateLimit() int { return r.getInt("auth_rate_limit_per_min") }
// LoginLockout returns (max attempts, lockout duration). Read once at startup.
func (r *SettingsRegistry) LoginLockout() (int, time.Duration) {
return r.getInt("login_max_attempts"), time.Duration(r.getInt("login_lockout_minutes")) * time.Minute
}
// OpdsDefaultPageSize is the default OPDS items-per-page.
func (r *SettingsRegistry) OpdsDefaultPageSize() int { return r.getInt("opds_default_page_size") }
// OpdsMaxPageSize is the maximum items-per-page a client may request.
func (r *SettingsRegistry) OpdsMaxPageSize() int { return r.getInt("opds_max_page_size") }
// DeviceRateLimits bundles the per-route device rate limits (requests/minute).
type DeviceRateLimits struct {
Sync int
Progress int
Metadata int
}
// DeviceRateLimits returns the active device rate limits.
func (r *SettingsRegistry) DeviceRateLimits() DeviceRateLimits {
return DeviceRateLimits{
Sync: r.getInt("device_rate_sync_per_min"),
Progress: r.getInt("device_rate_progress_per_min"),
Metadata: r.getInt("device_rate_metadata_per_min"),
}
}
// TombstoneTTL is how long deleted annotations are retained before purge.
func (r *SettingsRegistry) TombstoneTTL() time.Duration {
return time.Duration(r.getInt("annotation_tombstone_ttl_days")) * 24 * time.Hour
}
// ConversionCacheTTL is how long converted (kepub) files are served from cache.
func (r *SettingsRegistry) ConversionCacheTTL() time.Duration {
return time.Duration(r.getInt("conversion_cache_ttl_hours")) * time.Hour
}
// SyncQueueConfig bundles the sync queue interval and batch size. Read at
// startup; changes require a restart.
type SyncQueueConfig struct {
Interval time.Duration
BatchSize int
}
// SyncQueueConfig returns the active sync queue configuration.
func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig {
return SyncQueueConfig{
Interval: time.Duration(r.getInt("sync_queue_interval_seconds")) * time.Second,
BatchSize: r.getInt("sync_queue_batch_size"),
}
}
// WorkerPoolConfig bundles worker count and queue capacity. Read at startup;
// changes require a restart.
type WorkerPoolConfig struct {
Size int
QueueCap int
}
// WorkerPoolConfig returns the active worker pool configuration.
func (r *SettingsRegistry) WorkerPoolConfig() WorkerPoolConfig {
return WorkerPoolConfig{
Size: r.getInt("worker_pool_size"),
QueueCap: r.getInt("worker_queue_cap"),
}
}
// SettingEntry exposes one setting's metadata + current value, for the admin UI/API.
type SettingEntry struct {
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
Min string `json:"min,omitempty"`
Max string `json:"max,omitempty"`
RequiresRestart bool `json:"requires_restart"`
Category string `json:"category"`
Group string `json:"group"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
}
// All returns metadata + current values for every known setting, grouped by
// the in-memory cache (which reflects the DB after Load/Reload).
func (r *SettingsRegistry) All() []SettingEntry {
r.mu.RLock()
vals := make(map[string]string, len(r.values))
for k, v := range r.values {
vals[k] = v
}
r.mu.RUnlock()
out := make([]SettingEntry, 0, len(SettingDefaults))
for _, d := range SettingDefaults {
v, ok := vals[d.Key]
if !ok {
v = d.Value
}
out = append(out, SettingEntry{
Key: d.Key,
Value: v,
Type: d.Type,
Min: d.Min,
Max: d.Max,
RequiresRestart: d.RequiresRestart,
Category: d.Category,
Group: d.Group,
Description: d.Description,
IsDefault: v == d.Value,
})
}
return out
}
// LookupDefault returns the compiled-in SettingDefault for a key (ok=false if unknown).
func LookupDefault(key string) (SettingDefault, bool) {
d, ok := defaultBy[key]
return d, ok
}
@@ -1,97 +0,0 @@
package database
import (
"strconv"
"testing"
)
// TestSettingDefaults ensures every seeded setting has a compiled default with
// a valid value for its declared type. This guards against typos that would
// silently fall back at runtime.
func TestSettingDefaults(t *testing.T) {
if len(SettingDefaults) == 0 {
t.Fatal("SettingDefaults is empty")
}
for _, d := range SettingDefaults {
if d.Key == "" {
t.Errorf("default has empty key: %+v", d)
continue
}
switch d.Type {
case SettingTypeInt:
if _, err := strconv.Atoi(d.Value); err != nil {
t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err)
}
if d.Min != "" {
if _, err := strconv.Atoi(d.Min); err != nil {
t.Errorf("int setting %s min %q is not an int", d.Key, d.Min)
}
}
if d.Max != "" {
if _, err := strconv.Atoi(d.Max); err != nil {
t.Errorf("int setting %s max %q is not an int", d.Key, d.Max)
}
}
case SettingTypeBool:
if _, err := strconv.ParseBool(d.Value); err != nil {
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
}
case SettingTypeString:
if d.Value == "" {
t.Errorf("string setting %s has empty default", d.Key)
}
default:
t.Errorf("setting %s has unknown type %q", d.Key, d.Type)
}
}
}
// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are
// clamped to the declared min/max, and that garbage falls back to the default.
func TestSettingsRegistryGetIntClamping(t *testing.T) {
r := &SettingsRegistry{values: map[string]string{}, q: nil}
// Seed with an over-max value; expect clamping to the max (3600).
r.values["scan_poll_interval_seconds"] = "999999"
if got := r.ScanPollInterval(); got.Seconds() != 3600 {
t.Errorf("expected clamp to 3600, got %v", got)
}
// Seed with an under-min value; expect clamp to min (1).
r.values["scan_poll_interval_seconds"] = "0"
if got := r.ScanPollInterval(); got.Seconds() != 1 {
t.Errorf("expected clamp to 1, got %v", got)
}
// Seed with garbage; expect fallback to default (60).
r.values["scan_poll_interval_seconds"] = "not-a-number"
if got := r.ScanPollInterval(); got.Seconds() != 60 {
t.Errorf("expected fallback default 60, got %v", got)
}
}
// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback.
func TestSettingsRegistryGetBoolFallback(t *testing.T) {
r := &SettingsRegistry{values: map[string]string{}, q: nil}
r.values["auto_scan_enabled"] = "true"
if !r.AutoScanEnabled() {
t.Error("expected true")
}
r.values["auto_scan_enabled"] = "garbage"
// garbage falls back to default ("true")
if !r.AutoScanEnabled() {
t.Error("expected fallback to default true")
}
}
// TestLookupDefaultUnknownKey verifies unknown keys return ok=false.
func TestLookupDefaultUnknownKey(t *testing.T) {
if _, ok := LookupDefault("does_not_exist"); ok {
t.Error("expected ok=false for unknown key")
}
if _, ok := LookupDefault("session_duration_seconds"); !ok {
t.Error("expected ok=true for known key")
}
}
-167
View File
@@ -1,167 +0,0 @@
package handlers
import (
"context"
"net/http"
"time"
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
// DeletedAnnotationResponse is one entry of the deleted-annotation history
// for a book (the book page's "recently deleted" list). Restoring returns the
// row to the active set; purging removes it permanently.
type DeletedAnnotationResponse struct {
ID string `json:"id"`
AnnotationType string `json:"annotation_type"`
DisplayText string `json:"display_text"`
SecondaryText string `json:"secondary_text,omitempty"`
Color string `json:"color,omitempty"`
DeletedAt time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
}
// DeletedAnnotationsForBook builds the deleted-annotation history for a user
// and book. Shared by the JSON API and the book page's server-rendered modal.
func DeletedAnnotationsForBook(ctx context.Context, db *database.Queries, userID, mediaItemID pgtype.UUID) []DeletedAnnotationResponse {
rows, err := db.ListDeletedAnnotationsForBook(ctx, database.ListDeletedAnnotationsForBookParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil {
return []DeletedAnnotationResponse{}
}
response := make([]DeletedAnnotationResponse, 0, len(rows))
for _, row := range rows {
entry := DeletedAnnotationResponse{
ID: uuid.UUID(row.ID.Bytes).String(),
AnnotationType: row.AnnotationType,
DisplayText: row.DisplayText,
SecondaryText: row.SecondaryText.String,
Color: row.Color.String,
}
if row.DeletedAt.Valid {
entry.DeletedAt = row.DeletedAt.Time
}
if row.CreatedAt.Valid {
entry.CreatedAt = row.CreatedAt.Time
}
response = append(response, entry)
}
return response
}
// GetDeletedAnnotations handles GET /api/media-items/:id/annotations/deleted
func (mh *MediaHandler) GetDeletedAnnotations(c *echo.Context) error {
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
if err != nil {
return err
}
response := DeletedAnnotationsForBook(c.Request().Context(), mh.db, userUUID, mediaUUID)
return c.JSON(http.StatusOK, map[string]interface{}{
"deleted_annotations": response,
"total": len(response),
})
}
// RestoreDeletedAnnotation handles POST /api/media-items/:id/annotations/:annotationId/restore
// Body/query: annotation_type=highlight|note|bookmark
func (mh *MediaHandler) RestoreDeletedAnnotation(c *echo.Context) error {
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, true)
if errResp != nil {
return errResp
}
if mh.annotationSvc == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
}
restored, err := mh.annotationSvc.RestoreAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to restore annotation"})
}
if !restored {
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"restored": true})
}
// PurgeDeletedAnnotation handles DELETE /api/media-items/:id/annotations/:annotationId
// Query: annotation_type=highlight|note|bookmark. Permanent — removes the
// tombstoned row from the history.
func (mh *MediaHandler) PurgeDeletedAnnotation(c *echo.Context) error {
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, false)
if errResp != nil {
return errResp
}
if mh.annotationSvc == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
}
purged, err := mh.annotationSvc.PurgeAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to purge annotation"})
}
if !purged {
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"purged": true})
}
// parseUserAndMediaIDs extracts the authenticated user and the media item
// from the route. A non-nil error has already been written as the response.
func (mh *MediaHandler) parseUserAndMediaIDs(c *echo.Context) (pgtype.UUID, pgtype.UUID, error) {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaIDUUID, err := uuid.Parse(mediaID)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
return pgtype.UUID{Bytes: userUUID, Valid: true}, pgtype.UUID{Bytes: mediaIDUUID, Valid: true}, nil
}
// parseAnnotationHistoryRequest extracts user, media item, annotation ID, and
// the annotation_type (from query param or JSON body — restore posts a body,
// purge uses a query param). A non-nil error has already been written.
func (mh *MediaHandler) parseAnnotationHistoryRequest(c *echo.Context, allowBody bool) (pgtype.UUID, pgtype.UUID, pgtype.UUID, string, error) {
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", err
}
annotationID := c.Param("annotationId")
annotationUUID, err := uuid.Parse(annotationID)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid annotation id"})
}
kind := c.QueryParam("annotation_type")
if kind == "" && allowBody {
var body struct {
AnnotationType string `json:"annotation_type"`
}
if c.Bind(&body) == nil {
kind = body.AnnotationType
}
}
if !wsync.ValidAnnotationKind(kind) {
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "annotation_type must be highlight, note, or bookmark"})
}
return userUUID, mediaUUID, pgtype.UUID{Bytes: annotationUUID, Valid: true}, kind, nil
}
+28 -60
View File
@@ -6,7 +6,6 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"bookhoard/internal/setupstatus"
"context"
"errors"
"fmt"
@@ -26,34 +25,14 @@ import (
)
const (
// DefaultSessionDuration is the fallback session duration used when no
// settings registry is wired (matches the historical 7-day value).
DefaultSessionDuration = 7 * 24 * time.Hour
// Session duration constants
// Follows same pattern as refresh_token.go
SessionDuration = 7 * 24 * time.Hour // 7 days
)
// SessionDurationSec is retained for backward compatibility; new code uses the
// registry via AuthHandler.sessionDuration().
var SessionDurationSec = int(DefaultSessionDuration.Seconds())
// SetSettings wires the tunable settings registry (optional).
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
// sessionDuration returns the active session duration from the registry.
func (h *AuthHandler) sessionDuration() time.Duration {
if h.settings != nil {
return h.settings.SessionDuration()
}
return DefaultSessionDuration
}
// refreshTokenTTL returns the active refresh-token lifetime (shared with the
// session duration), with a compiled-default fallback.
func (h *AuthHandler) refreshTokenTTL() time.Duration {
if h.settings != nil {
return h.settings.SessionDuration()
}
return DefaultSessionDuration
}
// SessionDurationSec is the session duration in seconds for use in cookies and API responses
// Note: This is computed from SessionDuration to avoid magic numbers
var SessionDurationSec = int(SessionDuration.Seconds())
var secure = os.Getenv("COOKIE_SECURE")
@@ -61,7 +40,6 @@ type AuthHandler struct {
db *database.Queries
jwtKey []byte
loginAttemptTracker *middleware.LoginAttemptTracker
settings *database.SettingsRegistry
}
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
@@ -104,22 +82,22 @@ type UserProfile struct {
}
type UpdateProfileRequest struct {
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"`
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
}
type AdminUpdateUserRequest struct {
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"`
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
}
// Register handles POST /api/auth/register
@@ -212,7 +190,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
}
var userRole string
if !adminExists {
if len(users) == 0 {
userRole = "admin"
} else {
userRole = req.Role
@@ -254,10 +232,6 @@ 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>`)
@@ -286,7 +260,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
HttpOnly: true,
Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
MaxAge: int(h.sessionDuration().Seconds()),
MaxAge: SessionDurationSec,
}
c.SetCookie(cookie)
@@ -319,7 +293,7 @@ window.location.href = '/dashboard';
Token: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: int(h.sessionDuration().Seconds()),
ExpiresIn: SessionDurationSec,
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
@@ -432,7 +406,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
HttpOnly: true,
Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
MaxAge: int(h.sessionDuration().Seconds()),
MaxAge: SessionDurationSec,
}
c.SetCookie(cookie)
@@ -471,7 +445,7 @@ window.location.href = '%s';
Token: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: int(h.sessionDuration().Seconds()),
ExpiresIn: SessionDurationSec,
User: UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
@@ -574,9 +548,6 @@ 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)
@@ -814,9 +785,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
}
type PasswordRequest struct {
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"`
CurrentPassword string `json:"current_password,omitempty"`
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" validate:"required"`
}
var req PasswordRequest
@@ -963,9 +934,6 @@ 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 {
@@ -1035,7 +1003,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
"user_role": userRole,
"user_email": userEmail,
"user_username": userUsername,
"exp": time.Now().Add(h.sessionDuration()).Unix(),
"exp": time.Now().Add(SessionDuration).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+11 -100
View File
@@ -73,7 +73,6 @@ type BookInfo struct {
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
HasConflict bool `json:"has_conflict"`
}
type SectionData struct {
@@ -139,7 +138,6 @@ 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 {
@@ -154,7 +152,6 @@ 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))
@@ -162,26 +159,6 @@ 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: col.ID.Bytes,
Name: col.Name,
@@ -190,7 +167,6 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
Icon: textToString(col.Icon),
AutoAssignRules: col.AutoAssignRules,
CreatedAt: col.CreatedAt.Time.String(),
BookCount: bookCount,
})
}
@@ -221,84 +197,19 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
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}
books, err := h.GetCollectionBooksData(c, collectionID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
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),
})
}
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{}
+9 -154
View File
@@ -28,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
}
type ConflictResolutionRequest struct {
Winner string `json:"winner" validate:"required"`
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
ManualData map[string]interface{} `json:"manual_data"`
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
Reason string `json:"reason"`
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if conflict.ResolutionStatus.String == "user_resolved" {
if conflict.ResolutionStatus.String != "unresolved" {
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
}
@@ -235,10 +235,8 @@ 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 {
@@ -253,17 +251,11 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
}
if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, 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,
@@ -294,7 +286,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
return c.JSON(http.StatusOK, response)
}
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
@@ -350,7 +342,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: winnerSource, Valid: true},
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
@@ -362,143 +354,6 @@ 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 {
@@ -696,7 +551,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue
}
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
@@ -780,7 +635,7 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
return highestSource, highestData
}
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
@@ -835,8 +690,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
+12 -69
View File
@@ -4,7 +4,6 @@ import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"context"
"log"
"net/http"
"strconv"
@@ -31,13 +30,12 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
userUUID := uuid.UUID(user.ID.Bytes)
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}
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"})
}
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
@@ -65,7 +63,6 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
}
sectionData := BuildSections(sections, libraryID)
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
}
@@ -190,59 +187,6 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
return result
}
// activeConflictSet returns the set of media item IDs (as strings) that have an
// active (unresolved) progress sync conflict for the given user. A single query
// is issued; resolved conflicts are filtered out in memory.
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
if err != nil {
return nil
}
set := make(map[string]bool, len(conflicts))
for _, c := range conflicts {
if c.ResolutionStatus.String == "unresolved" {
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
}
}
return set
}
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
// active progress sync conflict for the user. It performs a single query
// regardless of how many books are passed.
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
if len(books) == 0 {
return books
}
set := activeConflictSet(ctx, db, userID)
for i := range books {
if set[books[i].MediaItemID] {
books[i].HasConflict = true
}
}
return books
}
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
// used by the dashboard which renders books grouped into sections.
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
if len(sections) == 0 {
return sections
}
set := activeConflictSet(ctx, db, userID)
if len(set) == 0 {
return sections
}
for s := range sections {
for i := range sections[s].Items {
if set[sections[s].Items[i].MediaItemID] {
sections[s].Items[i].HasConflict = true
}
}
}
return sections
}
func getViewAllURL(collectionID string, libraryID string) string {
if collectionID != "" {
if libraryID != "" {
@@ -258,15 +202,14 @@ func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
userUUID := uuid.UUID(user.ID.Bytes)
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}
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"})
}
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
// Return default preferences instead of 404 when none exist
+58 -66
View File
@@ -100,10 +100,6 @@ 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)
@@ -177,21 +173,60 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
}
if registration.Approved {
delete(pendingRegistrations, req.RegistrationID)
if registration.UserID == (uuid.UUID{}) {
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "approved",
AuthToken: registration.AuthToken,
DeviceID: registration.DeviceID,
SyncEndpoints: registration.SyncEndpoints,
Status: "pending",
Message: "awaiting user approval",
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
})
}
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: "pending",
Message: "awaiting user approval",
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
Status: "approved",
AuthToken: authToken,
DeviceID: device.ID.Bytes,
SyncEndpoints: syncEndpoints,
})
}
@@ -562,63 +597,14 @@ 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,
"approved": true, // Fixed: Add confirmation field for test compatibility
})
}
@@ -638,9 +624,15 @@ 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 == (uuid.UUID{}) {
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{
"registration_id": reg.RegistrationID,
"device_name": reg.DeviceName,
@@ -648,7 +640,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
"device_identifier": reg.DeviceIdentifier,
"expires_at": reg.ExpiresAt,
"created_at": reg.CreatedAt,
"is_approved": false,
"is_approved": reg.UserID != (uuid.UUID{}),
})
}
}
-255
View File
@@ -1,255 +0,0 @@
package handlers
import (
"bookhoard/internal/database"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
// HashConflictsHandler serves the admin Hash Conflicts page API: listing
// content-duplicate groups (same library + SHA-256 at different paths) and
// resolving them by keeping every copy or merging all but one.
type HashConflictsHandler struct {
db *database.Queries
}
func NewHashConflictsHandler(db *database.Queries) *HashConflictsHandler {
return &HashConflictsHandler{db: db}
}
// HashConflictItem is one copy in a conflict group, hydrated with per-item
// user-data counts so the admin can make an informed keep/merge choice.
type HashConflictItem struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Author string `json:"author,omitempty"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size,omitempty"`
CreatedAt string `json:"created_at"`
ProgressCount int64 `json:"progress_count"`
HighlightCount int64 `json:"highlight_count"`
BookmarkCount int64 `json:"bookmark_count"`
NoteCount int64 `json:"note_count"`
CollectionCount int64 `json:"collection_count"`
}
// HashConflictResponse is one pending conflict group.
type HashConflictResponse struct {
ID string `json:"id"`
LibraryID string `json:"library_id"`
LibraryName string `json:"library_name"`
SHA256 string `json:"sha256"`
CreatedAt string `json:"created_at"`
Items []HashConflictItem `json:"items"`
}
// ListHashConflicts returns all pending hash conflict groups with their member
// items and usage counts.
// GET /api/admin/hash-conflicts
func (h *HashConflictsHandler) ListHashConflicts(c *echo.Context) error {
ctx := c.Request().Context()
pending, err := h.db.ListPendingHashConflicts(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to list hash conflicts",
})
}
conflicts := make([]HashConflictResponse, 0, len(pending))
for _, p := range pending {
resp := HashConflictResponse{
ID: uuid.UUID(p.ID.Bytes).String(),
LibraryID: uuid.UUID(p.LibraryID.Bytes).String(),
LibraryName: p.LibraryName,
SHA256: p.FileSha256,
CreatedAt: p.CreatedAt.Time.Format(time.RFC3339),
Items: []HashConflictItem{},
}
items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
LibraryID: p.LibraryID,
})
if err != nil {
continue
}
for _, mi := range items {
counts, err := h.db.GetMediaItemUsageCounts(ctx, mi.ID)
if err != nil {
counts = database.GetMediaItemUsageCountsRow{}
}
resp.Items = append(resp.Items, HashConflictItem{
ID: uuid.UUID(mi.ID.Bytes),
Title: mi.Title,
Author: mi.Author.String,
FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64,
CreatedAt: mi.CreatedAt.Time.Format(time.RFC3339),
ProgressCount: counts.ProgressCount,
HighlightCount: counts.HighlightsCount,
BookmarkCount: counts.BookmarksCount,
NoteCount: counts.NotesCount,
CollectionCount: counts.CollectionsCount,
})
}
conflicts = append(conflicts, resp)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"conflicts": conflicts,
"total": len(conflicts),
})
}
// ResolveHashConflict resolves one conflict group.
//
// Form/JSON fields:
// - action=keep_all both copies are intentional; dismiss
// - action=keep&keep_uuid=<uuid> merge every other copy's child rows into the
// kept item (progress, highlights, bookmarks,
// notes, collections, ...) and delete the losers
//
// POST /api/admin/hash-conflicts/:id/resolve
func (h *HashConflictsHandler) ResolveHashConflict(c *echo.Context) error {
ctx := c.Request().Context()
conflictID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conflict ID"})
}
pgConflictID := pgtype.UUID{Bytes: conflictID, Valid: true}
conflict, err := h.db.GetHashConflict(ctx, pgConflictID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "conflict not found"})
}
if conflict.Status != "pending" {
return c.JSON(http.StatusConflict, map[string]string{"error": "conflict already resolved"})
}
action := c.FormValue("action")
keepUUIDStr := c.FormValue("keep_uuid")
if action == "" {
// Also accept a JSON body (htmx sends form-encoded, API clients may send JSON)
var body struct {
Action string `json:"action"`
KeepUUID string `json:"keep_uuid"`
}
if err := c.Bind(&body); err == nil && body.Action != "" {
action = body.Action
if keepUUIDStr == "" {
keepUUIDStr = body.KeepUUID
}
}
}
var pgUserID pgtype.UUID
if userID, ok := c.Get("user_id").(string); ok && userID != "" {
if u, err := uuid.Parse(userID); err == nil {
pgUserID = pgtype.UUID{Bytes: u, Valid: true}
}
}
switch action {
case "keep_all":
if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{
ID: pgConflictID,
Resolution: pgtype.Text{String: "keep_all", Valid: true},
ResolvedBy: pgUserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"})
}
return renderResolved(c, "All copies kept.")
case "keep":
keepUUID, err := uuid.Parse(keepUUIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is required for action=keep"})
}
pgKeepUUID := pgtype.UUID{Bytes: keepUUID, Valid: true}
// Validate the kept item belongs to this conflict group.
items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
FileSha256: pgtype.Text{String: conflict.FileSha256, Valid: true},
LibraryID: conflict.LibraryID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load conflict group"})
}
keepValid := false
for _, mi := range items {
if mi.ID.Bytes == pgKeepUUID.Bytes {
keepValid = true
break
}
}
if !keepValid {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is not part of this conflict"})
}
merged := 0
for _, mi := range items {
if mi.ID.Bytes == pgKeepUUID.Bytes {
continue
}
if err := h.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
Column1: pgKeepUUID,
Column2: mi.ID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to merge %q: %v", mi.FilePath, err),
})
}
if err := h.db.DeleteMediaItem(ctx, mi.ID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to delete %q: %v", mi.FilePath, err),
})
}
merged++
}
if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{
ID: pgConflictID,
Resolution: pgtype.Text{String: "kept:" + keepUUID.String(), Valid: true},
ResolvedBy: pgUserID,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"})
}
return renderResolved(c, fmt.Sprintf("Merged %d duplicate cop%s - all reading data preserved.",
merged, map[bool]string{true: "y", false: "ies"}[merged == 1]))
default:
return c.JSON(http.StatusBadRequest, map[string]string{"error": "action must be 'keep_all' or 'keep'"})
}
}
// renderResolved returns the htmx fragment swapped in place of a conflict card.
// Built inline (rather than via the templates package) because templates
// imports handlers and a back-import would be a cycle.
func renderResolved(c *echo.Context, message string) error {
html := fmt.Sprintf(`
<div class="card p-6 flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0"
style="background-color: var(--accent-muted); color: var(--accent);">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
</span>
<div>
<p class="font-medium" style="color: var(--text-primary);">Conflict resolved</p>
<p class="text-sm" style="color: var(--text-secondary);">%s</p>
</div>
</div>`, message)
return c.HTML(http.StatusOK, html)
}
+64 -316
View File
@@ -2,11 +2,8 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strings"
@@ -18,30 +15,19 @@ import (
)
type KoboHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver
bookResolver *services.BookResolver
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
}
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
return &KoboHandler{db: db, connManager: connManager, bookResolver: services.NewBookResolver(db)}
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) {
@@ -54,9 +40,8 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
if len(contentId) == 64 && looksLikeSHA256(contentId) {
// Try to find media item by SHA-256 (format-aware: also checks
// media_item_formats, so a converted/alternate format hash matches).
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx.Request().Context(), contentId)
// Try to find media item by SHA-256
mediaItem, err := h.db.GetMediaItemBySHA256(ctx.Request().Context(), pgtype.Text{String: contentId, Valid: true})
if err == nil {
// Found by SHA-256! Create device catalog entry for future lookups
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
@@ -254,16 +239,9 @@ type KoboInitResponse struct {
}
type KoboSyncStatus struct {
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"`
Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"`
}
type KoboServerSyncData struct {
@@ -327,7 +305,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
}
bookmarkCount := 0
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
UserID: pgUserID,
})
@@ -419,7 +397,6 @@ 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 {
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
@@ -429,23 +406,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
processedBooks[pgMediaUUID] = readingSync.ContentId
percentage := readingSync.PercentRead / 100.0
// 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,
@@ -453,8 +415,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
CurrentPage: currentPage,
TotalPages: totalPages,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
@@ -483,72 +443,29 @@ 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 != "" {
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++
}
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 != "" {
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++
}
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 != "" {
@@ -562,23 +479,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
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,
@@ -586,7 +486,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Epubcfi: &epubcfi,
ContextText: contextText,
Chapter: &chapter,
ChapterProgress: &chapterProgress,
DeviceType: "kobo",
@@ -625,32 +524,6 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
BookmarksSynced: bookmarksSynced,
}
if h.annotationSvc != nil && len(processedBooks) > 0 {
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), 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
@@ -693,67 +566,25 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
switch bookmarkSync.BookmarkType {
case "annotation":
if bookmarkSync.BookmarkText != "" {
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++
}
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 != "" {
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++
}
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmarkSync.BookmarkText,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
})
bookmarksSynced++
}
}
}
@@ -885,80 +716,37 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
for _, bookmark := range syncData.Bookmarks {
if bookmark.BookmarkType == "bookmark" {
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" {
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 {
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",
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmark.BookmarkText,
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSent++
}
} else {
bookmarksSent++
} else if bookmark.BookmarkType == "annotation" {
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},
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++
}
}
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
@@ -974,43 +762,3 @@ 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
}
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
package handlers
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
// The device pushes deletions as dedup-key arrays on the progress request.
// Verify the wire shape the plugin sends (lua json.encode of
// { deleted_highlights = { { dedup_key = "..." } } }) binds correctly.
func TestKOReaderProgressRequest_DeletedAnnotationsBinding(t *testing.T) {
payload := `{
"books": [{
"sha256": "d1b1c6123d6206017b40798744ed994f00803b97d22ce51bea32e95e1ce7a164",
"title": "1984",
"percentage": 0.42,
"deleted_highlights": [
{ "dedup_key": "abc123" },
{ "dedup_key": "def456" }
],
"deleted_bookmarks": [
{ "dedup_key": "789xyz" }
]
}]
}`
var req KOReaderProgressRequest
err := json.Unmarshal([]byte(payload), &req)
assert.NoError(t, err)
assert.Len(t, req.Books, 1)
book := req.Books[0]
assert.Len(t, book.DeletedHighlights, 2)
assert.Equal(t, "abc123", book.DeletedHighlights[0].DedupKey)
assert.Equal(t, "def456", book.DeletedHighlights[1].DedupKey)
assert.Len(t, book.DeletedBookmarks, 1)
assert.Equal(t, "789xyz", book.DeletedBookmarks[0].DedupKey)
}
// A request without the arrays (older plugins) must bind with them empty —
// deletion propagation is strictly opt-in per push.
func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
payload := `{"books": [{"sha256": "x", "title": "t", "percentage": 0.1}]}`
var req KOReaderProgressRequest
err := json.Unmarshal([]byte(payload), &req)
assert.NoError(t, err)
assert.Empty(t, req.Books[0].DeletedHighlights)
assert.Empty(t, req.Books[0].DeletedBookmarks)
}
+22 -313
View File
@@ -19,7 +19,6 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -115,51 +114,20 @@ type UpdateMediaNoteRequest struct {
// CreateMediaHighlightRequest represents the request for creating a media highlight
type CreateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"max=1000"`
EndPosition string `json:"end_position" validate:"max=1000"`
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
ChapterReference int32 `json:"chapter_reference"`
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteID string `json:"note_id"`
}
// UpdateMediaHighlightRequest represents the request for updating a media highlight
type UpdateMediaHighlightRequest struct {
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"max=1000"`
EndPosition string `json:"end_position" validate:"max=1000"`
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteText string `json:"note_text" validate:"max=10000"`
NoteID string `json:"note_id"`
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
ChapterReference int32 `json:"chapter_reference"`
}
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
type CreateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Position string `json:"position" validate:"max=100"`
Notes string `json:"notes" validate:"max=10000"`
CfiPosition string `json:"cfi_position" validate:"max=255"`
PageNumber int32 `json:"page_number"`
ChapterNumber int32 `json:"chapter_number"`
Percentage float64 `json:"percentage"`
ChapterReference int32 `json:"chapter_reference"`
}
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
type UpdateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Notes string `json:"notes" validate:"max=10000"`
Position string `json:"position" validate:"max=100"`
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
StartPosition string `json:"start_position" validate:"required,max=100"`
EndPosition string `json:"end_position" validate:"required,max=100"`
Color string `json:"color" validate:"omitempty,len=7"`
NoteID string `json:"note_id"`
}
type MediaHandler struct {
@@ -168,7 +136,6 @@ type MediaHandler struct {
libraryService *services.LibraryService
searchService *services.SearchService
progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
}
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
@@ -187,10 +154,6 @@ 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 {
@@ -1012,7 +975,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
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"`
@@ -1034,7 +996,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
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,
@@ -1057,16 +1018,6 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
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()})
@@ -1425,31 +1376,14 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, 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()})
}
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)
@@ -1510,11 +1444,7 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
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})
}
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()})
}
@@ -1583,35 +1513,9 @@ 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,
EpubcfiStart: req.EpubcfiStart,
EpubcfiEnd: req.EpubcfiEnd,
Color: color,
NoteText: req.NoteText,
PercentageStart: req.PercentageStart,
PercentageEnd: req.PercentageEnd,
ChapterReference: req.ChapterReference,
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: pgMediaID,
UserID: pgUserID,
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
@@ -1674,42 +1578,6 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
color = req.Color
}
// Prefer the sync-aware path: the same selection text + CFI resolves to
// the same dedup key, so this performs an LWW update of the existing row
// (including note_text and CFI columns the plain query cannot touch).
if mh.annotationSvc != nil {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
SelectionText: req.SelectionText,
StartPosition: req.StartPosition,
EndPosition: req.EndPosition,
EpubcfiStart: req.EpubcfiStart,
EpubcfiEnd: req.EpubcfiEnd,
Color: color,
NoteText: req.NoteText,
PercentageStart: req.PercentageStart,
PercentageEnd: req.PercentageEnd,
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, result.Highlight)
}
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
SelectionText: req.SelectionText,
@@ -1733,16 +1601,7 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
}
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)
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -1750,152 +1609,6 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
// GetMediaBookmarks handles GET /api/media-items/:id/bookmarks
func (mh *MediaHandler) GetMediaBookmarks(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
bookmarks, err := mh.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, bookmarks)
}
// CreateMediaBookmark handles POST /api/media-items/:id/bookmarks
func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req CreateMediaBookmarkRequest
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()})
}
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
// to the plain query when the service isn't wired (e.g. some tests).
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Title: req.Title,
Position: req.Position,
Notes: req.Notes,
PageNumber: req.PageNumber,
ChapterNumber: req.ChapterNumber,
CFIPosition: req.CfiPosition,
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
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.Bookmark)
}
bookmark, err := mh.db.CreateMediaBookmark(c.Request().Context(), database.CreateMediaBookmarkParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
PageNumber: pgtype.Int4{Int32: req.PageNumber, Valid: req.PageNumber > 0},
ChapterNumber: pgtype.Int4{Int32: req.ChapterNumber, Valid: req.ChapterNumber > 0},
CfiPosition: pgtype.Text{String: req.CfiPosition, Valid: req.CfiPosition != ""},
Title: req.Title,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, bookmark)
}
// UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId
func (mh *MediaHandler) UpdateMediaBookmark(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
bookmarkID := c.Param("bookmarkId")
bookmarkUUID, err := uuid.Parse(bookmarkID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
}
var req UpdateMediaBookmarkRequest
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()})
}
bookmark, err := mh.db.UpdateMediaBookmark(c.Request().Context(), database.UpdateMediaBookmarkParams{
ID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
Title: req.Title,
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, bookmark)
}
// DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId
func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
bookmarkID := c.Param("bookmarkId")
bookmarkUUID, err := uuid.Parse(bookmarkID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
}
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
if mh.annotationSvc != nil {
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// SearchMediaItems handles GET /api/media-items/search
// Supports two modes:
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
@@ -2010,10 +1723,6 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
"results": []interface{}{},
})
}
for i := range results {
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
}
return c.JSON(http.StatusOK, results)
}
-4
View File
@@ -18,8 +18,4 @@ type MediaDetail struct {
// Computed counts
NotesCount int `json:"notes_count"`
HighlightsCount int `json:"highlights_count"`
// Deleted-annotation history (tombstoned rows, newest first) — the book
// page's "recently deleted" list with restore/permanent-delete actions.
DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"`
}
+41 -206
View File
@@ -26,26 +26,6 @@ type OPDSHandler struct {
conversionService interface {
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
}
settings *database.SettingsRegistry
}
// SetSettings wires the tunable settings registry (OPDS page size).
func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
// opdsDefaultPageSize returns the configured default page size (50 if unset).
func (h *OPDSHandler) opdsDefaultPageSize() int {
if h.settings != nil {
return h.settings.OpdsDefaultPageSize()
}
return 50
}
// opdsMaxPageSize returns the configured maximum page size (200 if unset).
func (h *OPDSHandler) opdsMaxPageSize() int {
if h.settings != nil {
return h.settings.OpdsMaxPageSize()
}
return 200
}
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
@@ -58,118 +38,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
}
}
// Helper function to get base URL from system config with request-derived fallback
// Helper function to get base URL from system config
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
var dbBaseURL string
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
dbBaseURL = config.Value
}
baseURL := deriveBaseURL(c, dbBaseURL)
opdsBaseURL := baseURL + "/opds"
return baseURL, 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
}
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
// previous, next, last) and OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex) to a feed based on the current page position.
// catalogBase is the device catalog URL without query parameters. The token
// (device auth) is appended to every generated link.
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
totalPages := 0
if totalItems > 0 {
totalPages = (totalItems + perPageNum - 1) / perPageNum
}
startIdx := (pageNum - 1) * perPageNum
pagedURL := func(page int) string {
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
if err != nil {
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
}
// self reflects the current page; start/first point to the first page
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
feed.AddLink(pagedURL(1), catalogMediaType, "start")
feed.AddLink(pagedURL(1), catalogMediaType, "first")
if totalPages > 0 {
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
}
if pageNum > 1 {
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
}
if pageNum < totalPages {
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
}
feed.SetPagination(totalItems, perPageNum, startIdx+1)
}
// 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, ".")
}
opdsBaseURL := baseURL.Value + "/opds"
return baseURL.Value, opdsBaseURL, nil
}
// GetDeviceCatalog returns the OPDS catalog feed for a device
@@ -188,10 +65,9 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
}
}
perPageNum := h.opdsDefaultPageSize()
maxPerPage := h.opdsMaxPageSize()
perPageNum := 50
if perPage != "" {
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
perPageNum = num
}
}
@@ -263,17 +139,13 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
"Bookhoard Library",
)
// Feed links, including OPDS pagination links (first/previous/next/last) and
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
token := h.getAuthToken(c)
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
// Add feed links
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
// OpenSearch: the search link points to an OpenSearch description document
// (served by the same /search endpoint when no query is supplied) so that
// OPDS clients like KOReader can discover how to formulate search requests.
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
// Add entries
for _, item := range allItems {
@@ -298,21 +170,16 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String)
}
// 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 acquisition links
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
entry.AddAcquisitionLink(downloadURL, "application/epub+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")
}
// Add format variants
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
pdfURL := downloadURL + "&format=pdf"
entry.AddAlternateLink(pdfURL, "application/pdf")
}
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
entry.AddAlternateLink(pdfURL, "application/pdf")
// Add canonical identifier
entry.SetIdentifier(bookUUID)
@@ -346,17 +213,16 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
return c.String(http.StatusOK, xmlString)
}
// SearchDeviceCatalog searches the OPDS catalog for a device.
//
// When no "q" query parameter is supplied it returns an OpenSearch description
// document (application/opensearchdescription+xml) so that OPDS clients such as
// KOReader can discover the search URL template (which contains the
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
// acquisition feed of matching books.
// SearchDeviceCatalog searches the OPDS catalog for a device
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId")
query := c.QueryParam("q")
if query == "" {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
}
// Get base URLs
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil {
@@ -377,30 +243,12 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
}
token := h.getAuthToken(c)
// No query: serve the OpenSearch description document so clients can learn
// the search template (contains the {searchTerms} placeholder).
if query == "" {
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
desc := opds.NewSearchDescription(
"Bookhoard",
"Search the Bookhoard library",
searchURL,
)
xmlString, err := desc.GenerateXMLString()
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
}
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
return c.String(http.StatusOK, xmlString)
}
// Search media items
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
@@ -419,14 +267,11 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
)
// Add feed links
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(catalogURL, catalogMediaType, "start")
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
feed.AddLink(searchURL, catalogMediaType, "self")
// OpenSearch paging metadata (search results are a single page)
feed.SetPagination(len(allItems), len(allItems), 1)
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
// Add entries (same as catalog)
userUUID := uuid.UUID(userID)
@@ -451,15 +296,11 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String)
}
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
entry.AddAcquisitionLink(downloadURL, "application/epub+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")
}
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
entry.SetIdentifier(bookUUID)
@@ -605,12 +446,6 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
if mediaItem.MimeType.Valid {
mimeType = mediaItem.MimeType.String
}
// Always expose the primary content hash so clients (e.g. the koreader
// plugin) learn the canonical SHA-256 from the download response itself,
// not just from the feed metadata.
if mediaItem.FileSha256.Valid {
fileSha256 = mediaItem.FileSha256.String
}
}
// Check if file exists
@@ -764,7 +599,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
)
// Add feed links
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
catalogURL := fmt.Sprintf("%s/opds/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")
@@ -847,14 +682,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
formatList := []FormatInfo{}
// Add the primary/native format (always available if media item exists)
// Add EPUB format (always available if media item exists)
fileSize := int64(0)
if mediaItem.FileSize.Valid {
fileSize = mediaItem.FileSize.Int64
}
formatList = append(formatList, FormatInfo{
FormatType: formatLabelFromPath(mediaItem.FilePath),
FormatType: "epub",
FilePath: mediaItem.FilePath,
FileSha256: func() string {
if mediaItem.FileSha256.Valid {
@@ -956,7 +791,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/devices/%s/catalog", opdsBaseURL, deviceID)
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
return c.JSON(http.StatusOK, map[string]interface{}{
"opds_token": map[string]interface{}{
-119
View File
@@ -1,119 +0,0 @@
package handlers
import (
"bookhoard/internal/opds"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// rels collects the rel attributes of all links currently on the feed.
func rels(feed *opds.Feed) []string {
out := make([]string, 0, len(feed.Links))
for _, l := range feed.Links {
out = append(out, l.Rel)
}
return out
}
func containsRel(feed *opds.Feed, rel string) bool {
for _, l := range feed.Links {
if l.Rel == rel {
return true
}
}
return false
}
func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
// 1814 items, 50 per page => 37 pages; on page 2
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok")
assert.True(t, containsRel(feed, "self"))
assert.True(t, containsRel(feed, "start"))
assert.True(t, containsRel(feed, "first"))
assert.True(t, containsRel(feed, "last"))
assert.True(t, containsRel(feed, "previous"), "middle page must have previous")
assert.True(t, containsRel(feed, "next"), "middle page must have next")
// self must point to the current page
var selfHref string
for _, l := range feed.Links {
if l.Rel == "self" {
selfHref = l.Href
}
}
assert.Contains(t, selfHref, "page=2&per_page=50")
assert.Contains(t, selfHref, "token=tok")
// next must advance the page
var nextHref string
for _, l := range feed.Links {
if l.Rel == "next" {
nextHref = l.Href
}
}
assert.Contains(t, nextHref, "page=3")
// OpenSearch metadata
require.NotNil(t, feed.TotalResults)
assert.Equal(t, 1814, *feed.TotalResults)
require.NotNil(t, feed.ItemsPerPage)
assert.Equal(t, 50, *feed.ItemsPerPage)
require.NotNil(t, feed.StartIndex)
assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2")
}
func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "")
rels := rels(feed)
assert.NotContains(t, rels, "previous", "first page must not have previous")
assert.Contains(t, rels, "next")
}
func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "")
rels := rels(feed)
assert.NotContains(t, rels, "next", "last page must not have next")
assert.Contains(t, rels, "previous")
}
func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "")
rels := rels(feed)
assert.NotContains(t, rels, "previous")
assert.NotContains(t, rels, "next")
// still emits self/start/first/last
assert.Contains(t, rels, "self")
assert.Contains(t, rels, "last")
}
func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "")
rels := rels(feed)
assert.NotContains(t, rels, "next")
assert.NotContains(t, rels, "previous")
assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page")
require.NotNil(t, feed.TotalResults)
assert.Equal(t, 0, *feed.TotalResults)
}
func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc")
xml, err := feed.GenerateXMLString()
require.NoError(t, err)
assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links")
}
-6
View File
@@ -2,7 +2,6 @@ package handlers
import (
"bookhoard/internal/database"
"context"
"net/http"
"time"
@@ -140,8 +139,3 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
"message": "Issue deleted",
})
}
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID)
}
-10
View File
@@ -374,11 +374,6 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
deviceName = progress.LastSyncDevice.String
}
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
}
progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
@@ -391,11 +386,6 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
ProgressPercentage: progress.Percentage.Float64 * 100,
EpubCFI: epubcfi,
LastUpdated: lastUpdated,
DeviceIcon: getDeviceIcon(deviceName),
DeviceName: deviceName,
DeviceType: deviceName,
FormatGroup: mediaItem.FormatGroup,
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
})
+6 -2
View File
@@ -14,6 +14,10 @@ import (
"github.com/labstack/echo/v5"
)
const (
refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
)
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
@@ -68,7 +72,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
return c.JSON(http.StatusOK, RefreshTokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: int(h.refreshTokenTTL().Seconds()),
ExpiresIn: SessionDurationSec,
})
}
@@ -97,7 +101,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
tokenUUID := uuid.New()
refreshToken := tokenUUID.String()
expiresAt := time.Now().Add(h.refreshTokenTTL())
expiresAt := time.Now().Add(refreshTokenExpiration)
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
+3 -3
View File
@@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
// Set the folder paths (watch=true: this long-lived scanner reads events)
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
// Set the folder paths
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
}
@@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype
}
scanner := services.NewMediaScanner(h.db)
if err := scanner.SetFolders(folderPaths, true); err != nil {
if err := scanner.SetFolders(folderPaths); err != nil {
return fmt.Errorf("failed to set scanner folders: %v", err)
}
+17 -10
View File
@@ -9,7 +9,6 @@ import (
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
@@ -25,13 +24,12 @@ func NewSeriesHandler(db *database.Queries) *SeriesHandler {
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}
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"})
}
limit := 20
@@ -83,12 +81,21 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
}
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
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"})
}
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)
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), libUUID, seriesName)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
}
@@ -111,7 +118,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
})
}
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID uuid.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
svc := services.NewSeriesService(db)
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
}
+20 -78
View File
@@ -3,7 +3,6 @@ package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/setupstatus"
"encoding/json"
"fmt"
"net/http"
@@ -15,19 +14,14 @@ import (
)
type SidecarHandler struct {
db *database.Queries
cfg *config.Config
settings *database.SettingsRegistry
db *database.Queries
cfg *config.Config
}
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
return &SidecarHandler{db: db, cfg: cfg}
}
// SetSettings wires the tunable settings registry so the timezone write path
// keeps the cache consistent.
func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
type SidecarConfig struct {
Version string `json:"version"`
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
@@ -87,13 +81,15 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths (with request-derived fallback)
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value)
// Get base URL and compute paths
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -134,21 +130,6 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
SHA256: item.FileSha256.String,
FilePath: item.FilePath,
}
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
// holding a converted format resolves via the sidecar the same way it
// would via BookResolver on the server.
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
if ferr == nil {
entry := books[key]
for _, f := range formats {
if f.FileSha256.Valid && f.FileSha256.String != "" {
if _, exists := books[f.FileSha256.String]; !exists {
books[f.FileSha256.String] = entry
}
}
}
}
}
// Get collections
@@ -195,8 +176,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL + "/opds",
APIBaseURL: baseURL + "/api",
OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(),
DeviceToken: device.AuthToken,
},
@@ -238,13 +219,15 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
// Get base URL and compute paths (with request-derived fallback)
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
baseURL := deriveBaseURL(c, dbBaseURL.Value)
// Get base URL and compute paths
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
if baseURL.Value == "" {
baseURL.Value = h.cfg.BaseURL
}
// Generate URLs
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
// Get user's visible libraries with media items
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
@@ -284,21 +267,6 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
SHA256: item.FileSha256.String,
FilePath: item.FilePath,
}
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
// holding a converted format resolves via the sidecar the same way it
// would via BookResolver on the server.
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
if ferr == nil {
entry := books[key]
for _, f := range formats {
if f.FileSha256.Valid && f.FileSha256.String != "" {
if _, exists := books[f.FileSha256.String]; !exists {
books[f.FileSha256.String] = entry
}
}
}
}
}
// Get collections
@@ -341,8 +309,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
Bookhoard: SidecarBookhoardConfig{
OPDSCatalog: opdsCatalogURL,
SyncAPI: syncAPIURL,
OPDSBaseURL: baseURL + "/opds",
APIBaseURL: baseURL + "/api",
OPDSBaseURL: baseURL.Value + "/opds",
APIBaseURL: baseURL.Value + "/api",
DeviceID: deviceID.String(),
DeviceToken: device.AuthToken,
},
@@ -435,9 +403,6 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
"error": "failed to update default timezone",
})
}
if h.settings != nil {
h.settings.Reload(ctx)
}
continue
}
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
@@ -452,29 +417,6 @@ 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),
})
}
}
// Invalidate setup status cache so the middleware picks up the new
// base_url immediately (setup is not complete until base_url is set).
setupstatus.Invalidate()
}
// Check for HTMX request
if c.Request().Header.Get("HX-Request") == "true" {
// Fetch updated base_url for template
+1 -165
View File
@@ -2,21 +2,17 @@ package handlers
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type SystemSettingsHandler struct {
db *database.Queries
settings *database.SettingsRegistry
db *database.Queries
}
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
@@ -25,154 +21,6 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
}
}
// SetSettings wires the tunable settings registry. Required for the unified
// /api/system/settings endpoints and for cache invalidation after writes.
func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) {
h.settings = s
}
// reload refreshes the in-memory cache after a write.
func (h *SystemSettingsHandler) reload(c *echo.Context) {
if h.settings != nil {
h.settings.Reload(c.Request().Context())
}
}
// ---- Unified /api/system/settings endpoints ----
// GetSettings handles GET /api/system/settings.
func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error {
if h.settings == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
}
return c.JSON(http.StatusOK, h.settings.All())
}
// UpdateSettingRequest is the body for PUT /api/system/settings.
type UpdateSettingRequest struct {
Key string `json:"key" form:"key"`
Value string `json:"value" form:"value"`
}
// UpdateSettingResponse mirrors a settings entry plus a reload hint.
type UpdateSettingResponse struct {
database.SettingEntry
ReloadRequired bool `json:"reload_required"`
Message string `json:"message,omitempty"`
}
// UpdateSetting handles PUT /api/system/settings.
func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error {
if h.settings == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
}
var req UpdateSettingRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, resp)
}
// ApplySetting validates, persists, and reloads a single setting. Shared by the
// JSON API and the HTMX admin endpoint.
func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) {
if h.settings == nil {
return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized")
}
if key == "" {
return UpdateSettingResponse{}, fmt.Errorf("key is required")
}
def, ok := database.LookupDefault(key)
if !ok {
return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key)
}
if err := validateSettingValue(def, value); err != nil {
return UpdateSettingResponse{}, err
}
desc := def.Description
rType := pgtype.Text{}
if def.Type != "" {
rType = pgtype.Text{String: def.Type, Valid: true}
}
var minP, maxP pgtype.Text
if def.Min != "" {
minP = pgtype.Text{String: def.Min, Valid: true}
}
if def.Max != "" {
maxP = pgtype.Text{String: def.Max, Valid: true}
}
if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{
SettingKey: key,
SettingValue: value,
Description: pgtype.Text{String: desc, Valid: desc != ""},
SettingType: rType,
MinValue: minP,
MaxValue: maxP,
RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true},
Category: pgtype.Text{String: def.Category, Valid: def.Category != ""},
}); err != nil {
return UpdateSettingResponse{}, err
}
h.settings.Reload(ctx)
resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart}
for _, e := range h.settings.All() {
if e.Key == key {
resp.SettingEntry = e
break
}
}
if def.RequiresRestart {
resp.Message = "Saved. Restart the server for this change to take full effect."
} else {
resp.Message = "Saved."
}
return resp, nil
}
// validateSettingValue checks a candidate value against the setting's type and bounds.
func validateSettingValue(def database.SettingDefault, value string) error {
switch def.Type {
case database.SettingTypeInt:
n, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("value must be an integer")
}
if def.Min != "" {
if mn, err := strconv.Atoi(def.Min); err == nil && n < mn {
return fmt.Errorf("value must be >= %s", def.Min)
}
}
if def.Max != "" {
if mx, err := strconv.Atoi(def.Max); err == nil && n > mx {
return fmt.Errorf("value must be <= %s", def.Max)
}
}
case database.SettingTypeBool:
if _, err := strconv.ParseBool(value); err != nil {
return fmt.Errorf("value must be true or false")
}
case database.SettingTypeString:
if value == "" {
return fmt.Errorf("value must not be empty")
}
if def.Key == "default_timezone" {
if _, err := time.LoadLocation(value); err != nil {
return fmt.Errorf("invalid timezone: %v", err)
}
}
}
return nil
}
// ---- Legacy scan-settings endpoints (retained for backward compatibility) ----
type UpdateScanSettingsRequest struct {
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
AutoScanEnabled bool `json:"auto_scan_enabled"`
@@ -203,7 +51,6 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
if err != nil {
return err
}
h.reload(c)
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
@@ -241,8 +88,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
h.reload(c)
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
AutoScanEnabled: req.AutoScanEnabled,
@@ -251,15 +96,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
}
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
// Prefer the registry (single source of truth after Load).
if h.settings != nil {
interval := int32(h.settings.ScanPollInterval().Seconds())
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: interval,
AutoScanEnabled: h.settings.AutoScanEnabled(),
})
}
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
-36
View File
@@ -1,36 +0,0 @@
package handlers
import (
"strings"
"github.com/labstack/echo/v5"
)
// deriveBaseURL returns the base URL to use for constructing self-referential
// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured
// base_url when available, and falls back to deriving the URL from the incoming
// HTTP request (Host header + scheme), which is always reachable by the client.
//
// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so
// that deployments behind TLS-terminating reverse proxies advertise the correct
// external URL.
func deriveBaseURL(c *echo.Context, dbBaseURL string) string {
if dbBaseURL != "" {
return strings.TrimRight(dbBaseURL, "/")
}
scheme := "http"
if c.Request().TLS != nil {
scheme = "https"
}
if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
host := c.Request().Host
if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" {
host = forwarded
}
return scheme + "://" + host
}
+7 -40
View File
@@ -25,7 +25,6 @@ type DeviceContext struct {
type DeviceAuthMiddleware struct {
db *database.Queries
rateLimiter *DeviceRateLimiter
settings *database.SettingsRegistry
}
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
@@ -35,41 +34,6 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
}
}
// SetSettings wires the tunable settings registry so device rate limits are
// read live on each authenticated request.
func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s }
// rateLimitConfig returns the active device rate limits from the registry, or
// the historical defaults when no registry is wired.
func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig {
if m.settings != nil {
dl := m.settings.DeviceRateLimits()
return DeviceRateLimitConfig{
SyncRequestsPerMinute: dl.Sync,
ProgressUpdatesPerMinute: dl.Progress,
MetadataRequestsPerMinute: dl.Metadata,
}
}
return DeviceRateLimitConfig{
SyncRequestsPerMinute: DefaultSyncRequestsPerMinute,
ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute,
MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute,
}
}
// rateLimitForRequestType returns the configured per-minute limit for a given
// request type, for use in X-RateLimit-* headers.
func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int {
switch requestType {
case "progress":
return config.ProgressUpdatesPerMinute
case "metadata":
return config.MetadataRequestsPerMinute
default: // "sync" and any unknown type
return config.SyncRequestsPerMinute
}
}
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
var device database.Devices
@@ -151,12 +115,15 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
deviceUUID := uuid.UUID(device.ID.Bytes)
deviceID := deviceUUID.String()
config := m.rateLimitConfig()
limitForType := m.rateLimitForRequestType(requestType, config)
config := DeviceRateLimitConfig{
SyncRequestsPerMinute: 60,
ProgressUpdatesPerMinute: 120,
MetadataRequestsPerMinute: 30,
}
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
c.Response().Header().Set("X-RateLimit-Limit", "60")
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Response().Header().Set("X-RateLimit-Reset", "60")
return c.JSON(http.StatusTooManyRequests, map[string]string{
@@ -167,7 +134,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
}
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
c.Response().Header().Set("X-RateLimit-Limit", "60")
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
ctx := DeviceContext{
+51 -101
View File
@@ -1,146 +1,96 @@
package middleware
import (
"bookhoard/internal/database"
"fmt"
"regexp"
"sync"
"github.com/go-playground/validator/v10"
)
// specialCharRegex matches the historical "special character" set used by the
// password complexity rules.
const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
// PasswordValidator validates password complexity requirements
type PasswordValidator struct{}
// PasswordValidator validates password complexity against the configured rules.
// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the
// regex set is recompiled under a mutex on each validation. Without a registry
// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply.
type PasswordValidator struct {
settings *database.SettingsRegistry
}
// SetSettings wires the tunable settings registry.
func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s }
// compileSpecialRegex isolates the regexp compile (which is safe to call
// concurrently, but we keep it behind a cached var for the no-registry path).
var (
specialOnce sync.Once
specialRe *regexp.Regexp
)
func specialRegex() *regexp.Regexp {
specialOnce.Do(func() {
specialRe = regexp.MustCompile(specialCharRegex)
})
return specialRe
}
func (v *PasswordValidator) rules() database.PasswordRules {
if v.settings != nil {
return v.settings.PasswordRules()
}
return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true}
}
// Validate checks if a password meets the configured complexity requirements.
// Validate checks if a password meets complexity requirements:
// - Minimum 8 characters
// - At least one uppercase letter
// - At least one lowercase letter
// - At least one number
// - At least one special character
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
return v.CheckPassword(fl.Field().String())
}
password := fl.Field().String()
// CheckPassword applies the active rules to a single password.
func (v *PasswordValidator) CheckPassword(password string) bool {
r := v.rules()
if len(password) < r.MinLength {
// Check minimum length
if len(password) < 8 {
return false
}
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
// Check for uppercase
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
if !hasUpper {
return false
}
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
// Check for lowercase
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
if !hasLower {
return false
}
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
// Check for number
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
if !hasNumber {
return false
}
if r.Special && !specialRegex().MatchString(password) {
// Check for special character
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
if !hasSpecial {
return false
}
return true
}
// GetPasswordRequirements returns a human-readable list of the active password
// requirements, driven by the configured rules when a registry is wired.
// GetPasswordRequirements returns a human-readable list of password requirements
func GetPasswordRequirements() []string {
return defaultPasswordValidator.Requirements()
return []string{
"At least 8 characters long",
"At least one uppercase letter (A-Z)",
"At least one lowercase letter (a-z)",
"At least one number (0-9)",
"At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
}
}
// Requirements returns the human-readable list for the receiver's active rules.
func (v *PasswordValidator) Requirements() []string {
r := v.rules()
var out []string
out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength))
if r.Upper {
out = append(out, "At least one uppercase letter (A-Z)")
}
if r.Lower {
out = append(out, "At least one lowercase letter (a-z)")
}
if r.Number {
out = append(out, "At least one number (0-9)")
}
if r.Special {
out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)")
}
return out
}
// ValidatePassword checks a password against the default (hardcoded) rules and
// returns an error describing the first unmet requirement. Retained for callers
// that don't have access to a configured PasswordValidator instance.
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
func ValidatePassword(password string) error {
v := defaultPasswordValidator
r := v.rules()
if len(password) < r.MinLength {
return fmt.Errorf("password must be at least %d characters long", r.MinLength)
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one uppercase letter")
}
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
return fmt.Errorf("password must contain at least one lowercase letter")
}
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
return fmt.Errorf("password must contain at least one number")
}
if r.Special && !specialRegex().MatchString(password) {
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
return fmt.Errorf("password must contain at least one special character")
}
return nil
}
// defaultPasswordValidator is used by the package-level helpers
// (GetPasswordRequirements, ValidatePassword) and as the fallback inside
// RegisterPasswordValidation when no registry has been wired. Callers that want
// live rule updates should construct their own PasswordValidator and call
// SetSettings.
var defaultPasswordValidator = &PasswordValidator{}
// RegisterPasswordValidation registers the password validator with the
// validator instance. The registered func re-evaluates rules on every call, so
// changes to the wired registry take effect immediately.
// RegisterPasswordValidation registers the password validator with the validator instance
func RegisterPasswordValidation(v *validator.Validate) error {
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
return defaultPasswordValidator.CheckPassword(fl.Field().String())
pv := &PasswordValidator{}
return pv.Validate(fl)
})
}
// SetDefaultPasswordSettings wires the settings registry into the package-level
// default validator so that the struct-tag validator (used by echo's
// CustomValidator) and ValidatePassword follow live configuration. Intended to
// be called once at startup.
func SetDefaultPasswordSettings(s *database.SettingsRegistry) {
defaultPasswordValidator.SetSettings(s)
}
+21 -102
View File
@@ -9,25 +9,21 @@ import (
// OPDS 1.2 Feed Structures
type Feed struct {
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
OpdsNS string `xml:"xmlns:opds,attr"`
DcNS string `xml:"xmlns:dc,attr"`
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Links []Link `xml:"link"`
TotalResults *int `xml:"opensearch:totalResults,omitempty"`
ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"`
StartIndex *int `xml:"opensearch:startIndex,omitempty"`
Entries []Entry `xml:"entry"`
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
OpdsNS string `xml:"xmlns:opds,attr"`
DcNS string `xml:"xmlns:dc,attr"`
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Links []Link `xml:"link"`
Entries []Entry `xml:"entry"`
}
type Entry struct {
ID string `xml:"id"`
Title string `xml:"title"`
Author *Author `xml:"author,omitempty"`
Title string `xml:"dc:title"`
Creator string `xml:"dc:creator,omitempty"`
Updated string `xml:"updated"`
Summary string `xml:"summary,omitempty"`
Links []Link `xml:"link"`
@@ -36,12 +32,6 @@ 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"`
@@ -68,29 +58,17 @@ type Category struct {
func NewFeed(feedID, title string) *Feed {
now := time.Now().Format(time.RFC3339)
return &Feed{
Xmlns: "http://www.w3.org/2005/Atom",
OpdsNS: "http://opds-spec.org/2010/",
DcNS: "http://purl.org/dc/elements/1.1/",
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
ID: feedID,
Title: title,
Updated: now,
Links: []Link{},
Entries: []Entry{},
Xmlns: "http://www.w3.org/2005/Atom",
OpdsNS: "http://opds-spec.org/2010/",
DcNS: "http://purl.org/dc/elements/1.1/",
ID: feedID,
Title: title,
Updated: now,
Links: []Link{},
Entries: []Entry{},
}
}
// SetPagination populates the OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex). startIndex is 1-based to match the page model.
func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) {
tr := totalResults
ipp := itemsPerPage
si := startIndex
f.TotalResults = &tr
f.ItemsPerPage = &ipp
f.StartIndex = &si
}
// AddLink adds a link to the feed
func (f *Feed) AddLink(href, linkType, rel string) {
f.Links = append(f.Links, Link{
@@ -107,17 +85,14 @@ func (f *Feed) AddEntry(entry Entry) {
// NewEntry creates a new OPDS entry
func NewEntry(id, title, creator, updated string) Entry {
e := Entry{
return 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
@@ -194,62 +169,6 @@ func (f *Feed) GenerateXMLString() (string, error) {
return xml.Header + string(output), nil
}
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
type OpenSearchUrl struct {
XMLName xml.Name `xml:"Url"`
Type string `xml:"type,attr"`
Template string `xml:"template,attr"`
}
// OpenSearchDescription is an OpenSearch description document used by OPDS
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
// fetch this document at the catalog's rel="search" link, then substitute
// {searchTerms} in the Url template to execute a query.
type OpenSearchDescription struct {
XMLName xml.Name `xml:"OpenSearchDescription"`
Xmlns string `xml:"xmlns,attr"`
ShortName string `xml:"ShortName"`
Description string `xml:"Description"`
InputEncoding string `xml:"InputEncoding"`
OutputEncoding string `xml:"OutputEncoding"`
Url OpenSearchUrl `xml:"Url"`
}
// NewSearchDescription creates an OpenSearch description document whose Url
// template points clients back to the search results endpoint. The template
// must contain the {searchTerms} placeholder.
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
return &OpenSearchDescription{
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
ShortName: shortName,
Description: description,
InputEncoding: "UTF-8",
OutputEncoding: "UTF-8",
Url: OpenSearchUrl{
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
Template: template,
},
}
}
// GenerateXML generates the OpenSearch description XML
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
output, err := xml.MarshalIndent(d, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
}
return output, nil
}
// GenerateXMLString generates the OpenSearch description XML as a string
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
output, err := d.GenerateXML()
if err != nil {
return "", err
}
return xml.Header + string(output), nil
}
// NewErrorFeed creates an error feed
func NewErrorFeed(message string) *Feed {
feed := NewFeed(
+4 -105
View File
@@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) {
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
}
if entry.Author == nil || entry.Author.Name != "Test Author" {
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
if entry.Creator != "Test Author" {
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
}
if entry.Updated != "2023-01-01T00:00:00Z" {
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
`<title>Test Feed</title>`,
`<entry>`,
`<id>urn:uuid:book-id</id>`,
`<title>Test Book</title>`,
`<name>Test Author</name>`,
`<dc:title>Test Book</dc:title>`,
`<dc:creator>Test Author</dc:creator>`,
`<link href="http://example.com/book.epub"`,
`rel="http://opds-spec.org/acquisition/open-access"`,
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
@@ -232,107 +232,6 @@ func TestNewErrorFeed(t *testing.T) {
}
}
func TestFeedSetPagination(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
feed.SetPagination(1814, 50, 51)
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
}
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
}
if feed.StartIndex == nil || *feed.StartIndex != 51 {
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
}
}
func TestFeedGenerateXMLPagination(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
feed.SetPagination(1814, 50, 51)
output, err := feed.GenerateXML()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
outputStr := string(output)
requiredStrings := []string{
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
`<opensearch:totalResults>1814</opensearch:totalResults>`,
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
`<opensearch:startIndex>51</opensearch:startIndex>`,
`rel="first"`,
`rel="previous"`,
`rel="next"`,
`rel="last"`,
`page=3`,
}
for _, required := range requiredStrings {
if !contains(outputStr, required) {
t.Errorf("generated XML missing required string: %s", required)
}
}
}
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
output, err := feed.GenerateXML()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
outputStr := string(output)
if contains(outputStr, "opensearch:totalResults") {
t.Errorf("expected no totalResults when pagination unset, but found it")
}
if contains(outputStr, "opensearch:itemsPerPage") {
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
}
}
func TestNewSearchDescription(t *testing.T) {
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
desc := NewSearchDescription("Bookhoard", "Search the library", template)
if desc.ShortName != "Bookhoard" {
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
}
if desc.Url.Template != template {
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
}
}
func TestSearchDescriptionGenerateXML(t *testing.T) {
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
desc := NewSearchDescription("Bookhoard", "Search the library", template)
output, err := desc.GenerateXMLString()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
requiredStrings := []string{
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
`<ShortName>Bookhoard</ShortName>`,
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
}
for _, required := range requiredStrings {
if !contains(output, required) {
t.Errorf("generated OpenSearch XML missing required string: %s", required)
}
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && indexOf(s, substr) >= 0
}
-447
View File
@@ -1,447 +0,0 @@
package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/templates"
"bytes"
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
)
func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) {
g := frontendProtected.Group("", handlers.AdminMiddleware)
// HTMX: Create library
g.POST("/admin/library/create", func(c *echo.Context) error {
user := c.Get("user").(database.Users)
name := c.FormValue("name")
desc := c.FormValue("description")
libType := c.FormValue("type")
if name == "" || libType == "" {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name and type are required</div>`)
}
_, err := cfg.LibraryService.CreateLibrary(
c.Request().Context(),
name,
desc,
libType,
user.ID,
)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to create library</div>`)
}
return renderLibraryList(c, cfg)
})
// HTMX: Update library
g.PUT("/admin/library/:id", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
name := c.FormValue("name")
desc := c.FormValue("description")
if name == "" {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name is required</div>`)
}
_, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update library</div>`)
}
return renderLibraryList(c, cfg)
})
// HTMX: Delete library
g.DELETE("/admin/library/:id", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to delete library</div>`)
}
return renderLibraryList(c, cfg)
})
// HTMX: Library expanded panel
g.GET("/admin/library/:id/panel", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
return renderLibraryPanel(c, cfg, libraryID)
})
// HTMX: Add folder
g.POST("/admin/library/:id/folders", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
folderPath := c.FormValue("folder_path")
if folderPath == "" {
return renderLibraryPanel(c, cfg, libraryID)
}
if strings.Contains(folderPath, "..") {
return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed")
}
cleanPath := filepath.Clean(folderPath)
fileInfo, err := os.Stat(cleanPath)
if err != nil {
return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist")
}
if !fileInfo.IsDir() {
return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory")
}
_, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath)
if err != nil {
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error())
}
return renderLibraryPanel(c, cfg, libraryID)
})
// HTMX: Remove folder
g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
folderPath := c.FormValue("folder_path")
if folderPath == "" {
return renderLibraryPanel(c, cfg, libraryID)
}
err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath)
if err != nil {
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder")
}
return renderLibraryPanel(c, cfg, libraryID)
})
// HTMX: Folder browser
g.GET("/admin/library/browse", func(c *echo.Context) error {
path := c.QueryParam("path")
if path == "" {
path = "/"
}
targetInput := c.QueryParam("target_input")
libraryID := c.QueryParam("library_id")
dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path)
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Cannot browse: `+err.Error()+`</div>`)
}
entries := make([]templates.DirEntry, len(dirs))
for i, d := range dirs {
fullPath := filepath.Join(currentPath, d)
entries[i] = templates.DirEntry{Name: d, Path: fullPath}
}
var buf bytes.Buffer
err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// HTMX: Set user visibility for library
g.POST("/admin/library/:id/visibility", func(c *echo.Context) error {
libraryID, err := parseAdminUUID(c.Param("id"))
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
}
userIDStr := c.FormValue("user_id")
isVisible := c.FormValue("is_visible") == "true"
userID, err := parseAdminUUID(userIDStr)
if err != nil {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid user ID</div>`)
}
_, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible)
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update visibility</div>`)
}
return renderLibraryPanel(c, cfg, libraryID)
})
}
// renderLibraryList fetches all libraries + users and renders the LibraryList partial.
func renderLibraryList(c *echo.Context, cfg *Config) error {
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
if err != nil {
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to load libraries</div>`)
}
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID)
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
FolderCount: folderCount,
}
}
users, err := cfg.Queries.ListUsers(c.Request().Context())
if err != nil {
log.Printf("ListUsers failed: %v", err)
users = []database.ListUsersRow{}
}
userData := make([]templates.User, len(users))
for i, u := range users {
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
userData[i] = templates.User{
ID: userUUID.String(),
Username: u.Username,
Email: u.Email,
Role: u.Role,
}
}
var buf bytes.Buffer
err = templates.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}
// renderLibraryPanel fetches library details and renders the LibraryPanel partial.
func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error {
return renderLibraryPanelWithError(c, cfg, libraryID, "")
}
func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error {
ctx := c.Request().Context()
libraryIDStr := uuid.UUID(libraryID.Bytes).String()
// Get library details
lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID)
if err != nil {
return c.HTML(http.StatusNotFound, `<div class="text-sm" style="color: var(--status-danger);">Library not found</div>`)
}
libData := templates.LibraryData{
ID: libraryIDStr,
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
}
// Get folders
dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
if err != nil {
log.Printf("GetLibraryFolders failed: %v", err)
}
folders := make([]templates.FolderData, len(dbFolders))
for i, f := range dbFolders {
folders[i] = templates.FolderData{FolderPath: f.FolderPath}
}
libData.FolderCount = len(folders)
// Get users
dbUsers, err := cfg.Queries.ListUsers(ctx)
if err != nil {
log.Printf("ListUsers failed: %v", err)
dbUsers = []database.ListUsersRow{}
}
userData := make([]templates.User, len(dbUsers))
for i, u := range dbUsers {
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
userData[i] = templates.User{
ID: userUUID.String(),
Username: u.Username,
Email: u.Email,
}
}
// Get visibility for all users
visibility := make([]templates.UserVisibilityData, len(userData))
for i, u := range userData {
userUUID, _ := parseAdminUUID(u.ID)
visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID)
if err != nil {
log.Printf("GetUserVisibleLibraries failed: %v", err)
}
isVisible := false
for _, vl := range visibleLibs {
if vl.ID.Bytes == libraryID.Bytes {
isVisible = true
break
}
}
visibility[i] = templates.UserVisibilityData{
UserID: u.ID,
Username: u.Username,
Email: u.Email,
IsVisible: isVisible,
}
}
// Get issue count
issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID)
if err != nil {
log.Printf("GetProcessingIssueStats failed: %v", err)
}
issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount
// Get current user for template
tmplUser := templates.User{}
if u, ok := c.Get("user").(database.Users); ok {
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
tmplUser = templates.User{
ID: userUUID.String(),
Username: u.Username,
Role: u.Role,
}
}
var buf bytes.Buffer
err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf)
if err != nil {
return err
}
html := buf.String()
if errMsg != "" {
html = `<div class="p-3 mb-3 rounded-lg text-sm" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); color: var(--status-danger);">` + errMsg + `</div>` + html
}
return c.HTML(http.StatusOK, html)
}
func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int {
folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
if err != nil {
return 0
}
return len(folders)
}
func parseAdminUUID(s string) (pgtype.UUID, error) {
parsed, err := uuid.Parse(s)
if err != nil {
return pgtype.UUID{}, err
}
return pgtype.UUID{Bytes: parsed, Valid: true}, nil
}
func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats {
stats := templates.AdminStats{}
libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx)
stats.LibraryCount = len(libs)
users, _ := cfg.Queries.ListUsers(ctx)
stats.UserCount = len(users)
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount)
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount)
}
return stats
}
func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) {
g := frontendProtected.Group("", handlers.AdminMiddleware)
g.PUT("/admin/settings/scan", func(c *echo.Context) error {
ctx := c.Request().Context()
autoScan := c.FormValue("auto_scan_enabled") == "true"
intervalStr := c.FormValue("scan_poll_interval_seconds")
interval, err := strconv.Atoi(intervalStr)
if err != nil || interval < 1 || interval > 3600 {
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Interval must be between 1 and 3600 seconds</div>`)
}
autoScanStr := "false"
if autoScan {
autoScanStr = "true"
}
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
SettingKey: "auto_scan_enabled",
SettingValue: autoScanStr,
})
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
SettingKey: "scan_poll_interval_seconds",
SettingValue: strconv.Itoa(interval),
})
// Refresh the registry cache so the change is visible immediately.
if cfg.Settings != nil {
cfg.Settings.Reload(ctx)
}
scanSettings := templates.ScanSettingsData{
AutoScanEnabled: autoScan,
ScanPollIntervalSeconds: interval,
}
var buf bytes.Buffer
_ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf)
return c.HTML(http.StatusOK, buf.String())
})
// HTMX endpoint for saving a single tunable setting. Returns a small HTML
// status snippet rendered into the row's status span.
g.PUT("/admin/settings/tunable", func(c *echo.Context) error {
ctx := c.Request().Context()
key := c.FormValue("key")
value := c.FormValue("value")
if cfg.SystemSettingsHandler == nil {
return c.HTML(http.StatusServiceUnavailable, `<span style="color: var(--status-danger);">settings unavailable</span>`)
}
resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value)
if err != nil {
return c.HTML(http.StatusBadRequest, fmt.Sprintf(`<span style="color: var(--status-danger);">%s</span>`, err.Error()))
}
color := "var(--status-success)"
msg := "Saved"
if resp.ReloadRequired {
color = "var(--status-warning)"
msg = "Saved — restart required"
}
return c.HTML(http.StatusOK, fmt.Sprintf(`<span style="color: %s;">%s</span>`, color, msg))
})
}
+321 -278
View File
@@ -4,12 +4,12 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
@@ -111,6 +111,14 @@ 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
}
return ""
}
// Series browse page
frontendProtected.GET("/series", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
@@ -120,11 +128,36 @@ func registerFrontendRoutes(cfg *Config) {
var errorMsg string
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libData := libRes.Libraries
if libRes.IsAll {
errorMsg = ""
libraryID := c.QueryParam("library_id")
userUUID, _ := uuid.Parse(user.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()
} else {
errorMsg = "No libraries available"
}
}
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,
}
}
perSeriesPage := 24
@@ -139,28 +172,31 @@ func registerFrontendRoutes(cfg *Config) {
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{}
if libraryID != "" && errorMsg == "" {
libUUID, err := uuid.Parse(libraryID)
if err == nil {
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, 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,
})
}
seriesCards = append(seriesCards, templates.SeriesCardData{
Name: s.Name,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: covers,
})
}
}
}
@@ -191,23 +227,40 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "Series name required", "bad_request")
}
libraryID := c.QueryParam("library_id")
userUUID, _ := uuid.Parse(user.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()
} else {
errorMsg = "No libraries available"
}
}
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 libraryID != "" && errorMsg == "" {
libUUID, err := uuid.Parse(libraryID)
if err == nil {
svc := services.NewSeriesService(cfg.Queries)
books, err := svc.GetSeriesBooks(c.Request().Context(), libUUID, 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),
})
}
}
}
}
@@ -215,11 +268,8 @@ func registerFrontendRoutes(cfg *Config) {
bookInfoList = []handlers.BookInfo{}
}
seriesUserUUID, _ := uuid.Parse(user.ID)
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
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)
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books in this library yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -239,29 +289,41 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "Tag name required", "bad_request")
}
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libraryID := c.QueryParam("library_id")
userUUID, _ := uuid.Parse(user.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()
} else {
errorMsg = "No libraries available"
}
}
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),
})
libUUID, err := uuid.Parse(libraryID)
if err == nil {
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
LibraryID: uuidToPGType(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),
})
}
}
}
}
@@ -270,9 +332,6 @@ func registerFrontendRoutes(cfg *Config) {
bookInfoList = []handlers.BookInfo{}
}
tagUserUUID, _ := uuid.Parse(user.ID)
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList)
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 {
@@ -289,20 +348,52 @@ func registerFrontendRoutes(cfg *Config) {
var errorMsg string
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
libData := libRes.Libraries
// 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"
}
}
// Fetch saved filters for SSR
// Get libraries for dropdown
userUUID, _ := uuid.Parse(user.ID)
var savedFilters []database.SavedFilters
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
ResourceType: "media-items",
})
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("GetSavedFilters failed: %v", err)
savedFilters = []database.SavedFilters{}
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{}
}
}
// Fetch first page of books for SSR
@@ -311,56 +402,68 @@ func registerFrontendRoutes(cfg *Config) {
limit := 50
offset := 0
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 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 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)
}
}
}
var buf bytes.Buffer
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
if err != nil {
return err
@@ -377,13 +480,20 @@ func registerFrontendRoutes(cfg *Config) {
var errorMsg string
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
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()
}
}
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
pgLibUUID := libRes.LibUUID
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, pgLibUUID)
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
log.Printf("GetDashboardPreferences failed: %v", err)
prefs = database.UserDashboardPreferences{
@@ -401,7 +511,7 @@ func registerFrontendRoutes(cfg *Config) {
allSections, err := cfg.DashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
pgLibUUID,
libUUID,
limit,
prefs.CollectionOrder,
[]string{}, // No filtering - get all sections
@@ -415,12 +525,32 @@ func registerFrontendRoutes(cfg *Config) {
// Get only visible sections for the dashboard display
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
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, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -544,18 +674,30 @@ func registerFrontendRoutes(cfg *Config) {
userUUID, _ := uuid.Parse(user.ID)
var books []handlers.BookInfo
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
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()
}
}
libUUID, _ := uuid.Parse(libraryID)
dashboardSvc := services.NewDashboardService(cfg.Queries)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if err != 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])
@@ -571,21 +713,27 @@ func registerFrontendRoutes(cfg *Config) {
}
}
} else {
if libraryID != "" && !libRes.IsAll {
libUUID, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
// 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 {
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
}
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
// Use GetCollectionItemsForDashboard for library-filtered results
collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Limit: pgtype.Int4{Int32: 1000, Valid: true},
Limit: 1000,
})
if collErr != nil {
if err != 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 {
@@ -606,11 +754,13 @@ func registerFrontendRoutes(cfg *Config) {
books = bookCards
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if collErr != nil {
// 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 {
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])
@@ -631,11 +781,13 @@ func registerFrontendRoutes(cfg *Config) {
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
}
// 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, libRes.Libraries).Render(c.Request().Context(), &buf)
err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -734,7 +886,10 @@ func registerFrontendRoutes(cfg *Config) {
}
// Get base URL from database config with fallback to config/env var
baseURL := cfg.getBaseURL(c.Request().Context())
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
@@ -811,9 +966,8 @@ func registerFrontendRoutes(cfg *Config) {
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
stats := getAdminStats(c.Request().Context(), cfg)
var buf bytes.Buffer
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -825,9 +979,8 @@ func registerFrontendRoutes(cfg *Config) {
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
stats := getAdminStats(c.Request().Context(), cfg)
var buf bytes.Buffer
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
err = templates.Admin(user).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -851,14 +1004,11 @@ func registerFrontendRoutes(cfg *Config) {
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
FolderCount: len(folders),
}
}
@@ -945,67 +1095,6 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
}))
// Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
pending, err := cfg.Queries.ListPendingHashConflicts(c.Request().Context())
if err != nil {
return renderErrorPage(c, "Error loading hash conflicts", "conflicts_load_error")
}
conflicts := make([]templates.HashConflictData, 0, len(pending))
for _, p := range pending {
conflict := templates.HashConflictData{
ID: uuid.UUID(p.ID.Bytes).String(),
LibraryName: p.LibraryName,
SHA256: p.FileSha256,
SHAShort: p.FileSha256[:16] + "…",
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
Items: []templates.HashConflictItemData{},
}
items, err := cfg.Queries.ListMediaItemsBySHA256AndLibrary(c.Request().Context(), database.ListMediaItemsBySHA256AndLibraryParams{
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
LibraryID: p.LibraryID,
})
if err != nil {
continue
}
for _, mi := range items {
counts, err := cfg.Queries.GetMediaItemUsageCounts(c.Request().Context(), mi.ID)
if err != nil {
counts = database.GetMediaItemUsageCountsRow{}
}
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title,
Author: mi.Author.String,
FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
HasReadingData: totalData > 0,
})
}
conflicts = append(conflicts, conflict)
}
var buf bytes.Buffer
err = templates.AdminHashConflicts(user, conflicts).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin users page
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
@@ -1057,67 +1146,24 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
ctx := c.Request().Context()
// Fetch current system configuration - just base_url
baseURL := cfg.getBaseURL(ctx)
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
systemConfig := map[string]string{
"base_url": baseURL,
"default_timezone": "UTC",
}
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx)
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
if err == nil && defaultTimezone != "" {
systemConfig["default_timezone"] = defaultTimezone
}
// Fetch scan settings
scanSettings := templates.ScanSettingsData{
AutoScanEnabled: true,
ScanPollIntervalSeconds: 60,
}
if val, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled"); err == nil {
scanSettings.AutoScanEnabled = val == "true"
}
if val, err := cfg.Queries.GetSystemSetting(ctx, "scan_poll_interval_seconds"); err == nil {
if n, err := strconv.Atoi(val); err == nil {
scanSettings.ScanPollIntervalSeconds = n
}
}
// Load tunable settings entries from the registry. Exclude keys that
// already have their own dedicated UI cards (timezone dropdown, scan
// settings) so they aren't listed twice.
dedicatedUI := map[string]bool{
"default_timezone": true,
"scan_poll_interval_seconds": true,
"auto_scan_enabled": true,
}
var tunableSettings []templates.SettingEntry
if cfg.Settings != nil {
for _, e := range cfg.Settings.All() {
if dedicatedUI[e.Key] {
continue
}
tunableSettings = append(tunableSettings, templates.SettingEntry{
Key: e.Key,
Value: e.Value,
Type: e.Type,
Min: e.Min,
Max: e.Max,
RequiresRestart: e.RequiresRestart,
Category: e.Category,
Group: e.Group,
Description: e.Description,
IsDefault: e.IsDefault,
})
}
}
liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings)
var buf bytes.Buffer
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -1159,11 +1205,6 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
}))
// Admin library HTMX endpoints
registerAdminLibraryRoutes(cfg, frontendProtected)
// Admin settings HTMX endpoints
registerAdminSettingsRoutes(cfg, frontendProtected)
// ============================================================================
// LEGACY API ROUTES (for backward compatibility)
// ============================================================================
@@ -1197,7 +1238,10 @@ func registerFrontendRoutes(cfg *Config) {
}
// Get base URL from database config with fallback to config/env var
baseURL := cfg.getBaseURL(c.Request().Context())
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
@@ -1329,14 +1373,13 @@ func registerFrontendRoutes(cfg *Config) {
// Assemble response (no field duplication!)
detail := handlers.MediaDetail{
MediaItems: mediaItem, // Embedded - ALL fields available
Rating: rating,
Collections: collections,
ReadingProgress: progress,
ActiveConflict: activeConflict,
NotesCount: len(notes),
HighlightsCount: len(highlights),
DeletedAnnotations: handlers.DeletedAnnotationsForBook(c.Request().Context(), cfg.Queries, pgUserID, pgMediaUUID),
MediaItems: mediaItem, // Embedded - ALL fields available
Rating: rating,
Collections: collections,
ReadingProgress: progress,
ActiveConflict: activeConflict,
NotesCount: len(notes),
HighlightsCount: len(highlights),
}
// Render template
+1 -90
View File
@@ -3,10 +3,7 @@ package router
import (
"context"
"log"
"net/url"
"time"
"bookhoard/internal/database"
"bookhoard/templates"
"github.com/google/uuid"
@@ -67,7 +64,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"].(time.Time).Format(time.RFC3339),
ExpiresAt: p["expires_at"].(string),
}
}
return result
@@ -89,89 +86,3 @@ func parseUUID(s string) (uuid.UUID, error) {
func uuidToPGType(u uuid.UUID) pgtype.UUID {
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{}
}
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
if countErr != nil {
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
counts = []database.GetVisibleLibraryMediaCountsRow{}
}
countMap := make(map[string]int64, len(counts))
for _, mc := range counts {
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
countMap[mcUUID.String()] = mc.MediaCount
}
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,
MediaCount: countMap[libUUID.String()],
}
}
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
}
-2
View File
@@ -38,8 +38,6 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
libraryID := c.Param("id")
scanReq := map[string]interface{}{
-13
View File
@@ -41,19 +41,6 @@ func registerMediaRoutes(cfg *Config) {
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight)
// Bookmark routes (all authenticated users)
protected.GET("/media-items/:id/bookmarks", cfg.MediaHandler.GetMediaBookmarks)
protected.POST("/media-items/:id/bookmarks", cfg.MediaHandler.CreateMediaBookmark)
protected.PUT("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.UpdateMediaBookmark)
protected.DELETE("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.DeleteMediaBookmark)
// Deleted-annotation history (all authenticated users): tombstoned
// highlights/notes/bookmarks restorable or permanently removable from the
// book page's "recently deleted" list.
protected.GET("/media-items/:id/annotations/deleted", cfg.MediaHandler.GetDeletedAnnotations)
protected.POST("/media-items/:id/annotations/:annotationId/restore", cfg.MediaHandler.RestoreDeletedAnnotation)
protected.DELETE("/media-items/:id/annotations/:annotationId", cfg.MediaHandler.PurgeDeletedAnnotation)
// Admin-only media routes
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
+3 -46
View File
@@ -39,7 +39,6 @@ type Config struct {
Echo *echo.Echo
Queries *database.Queries
Cfg *config.Config
Settings *database.SettingsRegistry
DBPool interface{} // pgxpool.Pool interface
AuthHandler *handlers.AuthHandler
LibraryHandler *handlers.LibraryHandler
@@ -47,7 +46,6 @@ type Config struct {
MediaHandler *handlers.MediaHandler
MatchingHandler *handlers.MatchingHandler
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
HashConflictsHandler *handlers.HashConflictsHandler
KOReaderHandler *handlers.KOReaderHandler
WSHandler *handlers.WSHandler
ConflictHandler *handlers.ConflictHandler
@@ -64,32 +62,12 @@ type Config struct {
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
}
// getBaseURL returns the configured base URL from the database, falling back to
// the env var / config default. Uses a closure to adapt the database query to
// config.SystemConfigGetter.
func (cfg *Config) getBaseURL(ctx context.Context) string {
getter := func(ctx context.Context, key string) (string, error) {
row, err := cfg.Queries.GetSystemConfig(ctx, key)
if err != nil {
return "", err
}
return row.Value, nil
}
baseURL := config.GetBaseURL(ctx, getter)
if baseURL == "" {
baseURL = cfg.Cfg.BaseURL
}
return baseURL
}
// createJWTMiddleware creates a JWT middleware with proper user context setup
@@ -210,15 +188,10 @@ 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. The per-minute value comes from the settings registry (DB);
// the enabled flag stays env-driven since disabling rate limiting is a
// deployment-time decision, not a runtime tunable.
// Rate limiter
rateLimiterConfig := ratelimit.RateLimiterConfig{
Enabled: cfg.Cfg.RateLimitEnabled,
RequestsPerMinute: cfg.Settings.AuthRateLimit(),
RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
CleanupInterval: 5 * time.Minute,
}
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
@@ -233,7 +206,6 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
cfg.ScannerHandler = scannerHandler
// Register route groups
registerSetupRoutes(cfg)
registerAuthRoutes(cfg, rateLimitMiddleware)
registerLibraryRoutes(cfg)
registerDeviceRoutes(cfg)
@@ -295,17 +267,6 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
}
}()
// One-time hash backfill: compute and store SHA-256 for media items
// imported before hashing existed, then flag any content-duplicate groups
// for admin review on the Hash Conflicts page. Runs independently of
// auto-scan (it is a one-shot self-heal, not a recurring scan) and is a
// no-op once every item is hashed. Delayed so it does not compete with
// startup scans for disk I/O.
go func() {
time.Sleep(30 * time.Second)
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
}()
// Register progress routes with actual handler
registerProgressRoutes(cfg, scannerHandler)
@@ -313,9 +274,5 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
admin := protected.Group("", handlers.AdminMiddleware)
registerScannerRoutes(admin, scannerHandler)
// Hash conflict routes (admin only)
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
admin.POST("/api/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict)
return scannerHandler
}
+2 -8
View File
@@ -112,15 +112,9 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
}
}
// Stamp active conflict flags so cards route the play action correctly
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
// Render using BooksGrid template (or BookPickerGrid for collection picker)
// Render using BooksGrid template
var buf bytes.Buffer
if c.QueryParam("show_checkbox") == "true" {
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
} else {
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
}
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
log.Printf("Template render error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
-90
View File
@@ -1,90 +0,0 @@
package router
import (
"bytes"
"context"
"log"
"net/http"
"strings"
"bookhoard/internal/setupstatus"
"bookhoard/templates"
"github.com/labstack/echo/v5"
)
func isSetupComplete(cfg *Config) bool {
getter := func(ctx context.Context) (string, error) {
row, err := cfg.Queries.GetSystemConfig(ctx, "base_url")
if err != nil {
return "", err
}
return row.Value, nil
}
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter)
}
// setupAllowedAPIRoutes lists API endpoints that remain accessible before
// initial setup is complete so the server can be configured via API.
var setupAllowedAPIRoutes = []string{
"/api/auth/register",
"/api/auth/login",
"/api/system/config",
}
// isAllowedDuringSetup reports whether a request path should bypass the setup
// gate. This includes the setup page itself, static assets, health checks, and
// the minimal set of API routes needed to perform initial configuration.
func isAllowedDuringSetup(path string) bool {
if path == "/setup" || path == "/setup/" {
return true
}
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
return true
}
for _, route := range setupAllowedAPIRoutes {
if path == route || strings.HasPrefix(path, route+"/") {
return true
}
}
return false
}
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 isAllowedDuringSetup(path) {
return next(c)
}
if !isSetupComplete(cfg) {
if strings.HasPrefix(path, "/api/") {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
"error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.",
})
}
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
@@ -24,7 +24,6 @@ func registerSyncRoutes(cfg *Config) {
// KOReader sync routes (device authentication required)
koreaderSync := e.Group("/api/sync/koreader")
koreaderSync.POST("/progress", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncProgress))
koreaderSync.GET("/resolve", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.ResolveBook))
koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata))
koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks))
@@ -34,8 +33,6 @@ func registerSyncRoutes(cfg *Config) {
// 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))
-6
View File
@@ -17,10 +17,4 @@ func registerSystemRoutes(cfg *Config) {
// System configuration routes (admin-only)
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
// Unified tunable settings (admin-only). These back the admin UI's
// editable System Settings sections and supersede the legacy
// /api/libraries/scan-settings JSON routes.
system.GET("/settings", cfg.SystemSettingsHandler.GetSettings)
system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting)
}
+19 -15
View File
@@ -46,15 +46,13 @@ type LinkBookRequest struct {
// BookMatchingService handles universal book matching
type BookMatchingService struct {
db *database.Queries
resolver *BookResolver
db *database.Queries
}
// NewBookMatchingService creates a new book matching service
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
return &BookMatchingService{
db: db,
resolver: NewBookResolver(db),
db: db,
}
}
@@ -169,21 +167,27 @@ func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []
return nil
}
// matchBySHA256 attempts to match by file SHA-256 hash.
// Uses the shared BookResolver so it is both indexed (no full-table scan) and
// format-aware: a converted/alternate format hash (media_item_formats) matches
// in addition to the primary media_items.file_sha256.
// matchBySHA256 attempts to match by file SHA-256 hash
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
item, method, err := s.resolver.ResolveBySHA256(ctx, sha256)
if err != nil || !item.ID.Valid {
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
return nil
}
return &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.9,
MatchMethod: "sha256_" + string(method),
for _, item := range items {
if item.FileSha256.Valid && item.FileSha256.String == sha256 {
return &BookMatch{
MediaItemID: item.ID.Bytes,
BookhoardUUID: item.ID.Bytes,
Confidence: 0.9,
MatchMethod: "sha256_match",
}
}
}
return nil
}
// matchByOPFIdentifier attempts to match by OPF identifier
-68
View File
@@ -1,68 +0,0 @@
package services
import (
"bookhoard/internal/database"
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// ResolveMethod describes how a media item was resolved from a client-supplied identifier.
type ResolveMethod string
const (
MethodNone ResolveMethod = ""
MethodSHA256 ResolveMethod = "sha256" // matched on media_items.file_sha256
MethodSHA256Format ResolveMethod = "sha256_format" // matched on media_item_formats.file_sha256 (converted/alternate format)
)
// BookResolver is the single shared path from a client-supplied identifier to a
// media_item.
//
// All client/sync interfaces (koreader, kobo, OPDS, the device-link UI, and any
// future mobile app) should resolve books through BookResolver so they share
// identical matching semantics. In particular it provides format-aware SHA-256
// matching: a converted file (KEPUB/PDF) whose hash lives in media_item_formats
// resolves just as well as the primary format. The import-time SHA-256 is the
// canonical shared identifier across every client.
type BookResolver struct {
db *database.Queries
}
// NewBookResolver constructs a resolver backed by the given queries.
func NewBookResolver(db *database.Queries) *BookResolver {
return &BookResolver{db: db}
}
// ResolveBySHA256 resolves a media item by its content hash. It checks the
// primary media_items.file_sha256 first, then media_item_formats.file_sha256 so
// that a converted/alternate format (KEPUB, PDF, ...) also matches. Returns the
// matched item and how it matched, or pgx.ErrNoRows when no item has this hash.
func (r *BookResolver) ResolveBySHA256(ctx context.Context, sha256 string) (database.MediaItems, ResolveMethod, error) {
if sha256 == "" {
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
}
sha := pgtype.Text{String: sha256, Valid: true}
// 1. Primary content hash (the file the media item was imported from).
if mi, err := r.db.GetMediaItemBySHA256(ctx, sha); err == nil {
return mi, MethodSHA256, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (primary): %w", err)
}
// 2. Per-format hash (a converted/alternate format: KEPUB, PDF, ...).
formatRow, err := r.db.GetMediaItemFormatBySHA256(ctx, sha)
if err == nil {
if mi, err := r.db.GetMediaItem(ctx, formatRow.MediaItemID); err == nil {
return mi, MethodSHA256Format, nil
}
} else if !errors.Is(err, pgx.ErrNoRows) {
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (format): %w", err)
}
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
}
+2 -22
View File
@@ -16,10 +16,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
// defaultConversionCacheTTL is the fallback kepub cache lifetime when no
// settings registry is wired. Matches the historical hardcoded 24h.
const defaultConversionCacheTTL = 24 * time.Hour
type ConvertedKEPUB struct {
Path string
SHA256 string
@@ -31,7 +27,6 @@ type ConversionService struct {
cacheDir string
conversionTool string
conversionCacheTTL time.Duration
settings *database.SettingsRegistry
}
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
@@ -39,32 +34,17 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ
db: db,
cacheDir: cacheDir,
conversionTool: "/usr/bin/kepubify",
conversionCacheTTL: defaultConversionCacheTTL,
conversionCacheTTL: 24 * time.Hour,
}
}
// SetSettings wires the tunable settings registry. When wired, the cache TTL
// is read live on each conversion request.
func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
// cacheTTL returns the active conversion cache TTL.
func (s *ConversionService) cacheTTL() time.Duration {
if s.settings != nil {
return s.settings.ConversionCacheTTL()
}
if s.conversionCacheTTL > 0 {
return s.conversionCacheTTL
}
return defaultConversionCacheTTL
}
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
MediaItemID: mediaItemID,
FormatType: "kepub",
})
if err == nil && existing.FilePath.Valid {
if time.Since(existing.CreatedAt.Time) < s.cacheTTL() {
if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL {
return &ConvertedKEPUB{
Path: existing.FilePath.String,
SHA256: existing.FileSha256.String,
+1 -3
View File
@@ -65,7 +65,5 @@ func TestConversionServiceDefaults(t *testing.T) {
assert.NotNil(t, service)
assert.Equal(t, cacheDir, service.cacheDir)
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field should be 24 hours")
// cacheTTL() must reflect the same default when no registry is wired.
assert.Equal(t, int64(24*3600*1000000000), service.cacheTTL().Nanoseconds(), "Default TTL accessor should return 24 hours")
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours")
}
+18 -19
View File
@@ -135,8 +135,7 @@ type DashboardSection struct {
func (s *DashboardService) GetDashboardSections(
ctx context.Context,
userID uuid.UUID,
libraryID pgtype.UUID,
userID, libraryID uuid.UUID,
limit int,
collectionOrder []string,
hiddenCollections []string,
@@ -268,36 +267,36 @@ func (s *DashboardService) sortByPriority(sections []DashboardSection) []Dashboa
return sorted
}
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.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: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "recently-added":
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "recently-read":
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "not-started":
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "continue-series":
rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{
LibraryID: libraryID,
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
@@ -312,13 +311,13 @@ func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, co
}
}
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.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: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
@@ -335,7 +334,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, libraryID)
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
if err == nil {
for _, item := range allLibraryItems {
alreadyInCollection := false
@@ -375,10 +374,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
return finalItems, nil
}
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: libraryID,
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
})
}
-119
View File
@@ -1,119 +0,0 @@
package services
import (
"bookhoard/internal/database"
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// HashBackfillService is a one-time self-heal pass that computes and stores the
// SHA-256 for media items imported before hashing existed (file_sha256 IS
// NULL). It runs once shortly after startup, independently of auto-scan, and
// also performs a final conflict sweep that flags any content-duplicate groups
// (same library + SHA-256 at different paths) on the admin Hash Conflicts page.
//
// The sweep runs after the per-item pass because during the pass only one side
// of a preexisting duplicate pair may be hashed at a time - the group only
// becomes visible once every item has its hash.
type HashBackfillService struct {
db *database.Queries
libSvc *LibraryService
}
// NewHashBackfillService creates a backfill service.
func NewHashBackfillService(db *database.Queries) *HashBackfillService {
return &HashBackfillService{db: db, libSvc: NewLibraryService(db)}
}
// Run performs the backfill pass followed by the conflict sweep. It logs
// progress and never returns an error - failures on individual items are
// skipped so one unreadable file cannot block the rest.
func (s *HashBackfillService) Run(ctx context.Context) {
items, err := s.db.ListMediaItemsMissingHash(ctx)
if err != nil {
log.Printf("[HASH-BACKFILL] failed to list items missing hash: %v", err)
return
}
if len(items) == 0 {
log.Printf("[HASH-BACKFILL] all media items already hashed, nothing to do")
s.sweepConflicts(ctx)
return
}
log.Printf("[HASH-BACKFILL] computing SHA-256 for %d unhashed media items", len(items))
started := time.Now()
hashed, failed := 0, 0
for _, item := range items {
if ctx.Err() != nil {
log.Printf("[HASH-BACKFILL] cancelled after %d items", hashed)
return
}
path, err := s.libSvc.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
if err != nil {
log.Printf("[HASH-BACKFILL] could not resolve path for %q: %v", item.FilePath, err)
failed++
continue
}
sha, err := computeFileSHA256(path)
if err != nil {
log.Printf("[HASH-BACKFILL] could not hash %q: %v", path, err)
failed++
continue
}
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
ID: item.ID,
FileSha256: pgtype.Text{String: sha, Valid: true},
HashConfidence: pgtype.Text{String: "sha256_full", Valid: true},
})
if err != nil {
log.Printf("[HASH-BACKFILL] could not store hash for %q: %v", item.FilePath, err)
failed++
continue
}
hashed++
if hashed%25 == 0 {
log.Printf("[HASH-BACKFILL] progress: %d/%d hashed", hashed, len(items))
}
}
log.Printf("[HASH-BACKFILL] done in %s: %d hashed, %d failed (of %d)",
time.Since(started).Round(time.Second), hashed, failed, len(items))
s.sweepConflicts(ctx)
}
// sweepConflicts flags every content-duplicate group (same library + SHA-256,
// more than one item) as a pending hash conflict. The upsert is a no-op for
// groups that are already tracked or resolved, so admins who chose "keep both"
// are never re-prompted.
func (s *HashBackfillService) sweepConflicts(ctx context.Context) {
groups, err := s.db.FindHashConflictGroups(ctx)
if err != nil {
log.Printf("[HASH-BACKFILL] conflict sweep failed: %v", err)
return
}
if len(groups) == 0 {
return
}
flagged := 0
for _, g := range groups {
if err := s.db.CreateHashConflict(ctx, database.CreateHashConflictParams{
LibraryID: g.LibraryID,
FileSha256: g.FileSha256.String,
}); err != nil {
log.Printf("[HASH-BACKFILL] could not record conflict group: %v", err)
continue
}
flagged++
}
log.Printf("[HASH-BACKFILL] flagged %d content-duplicate group(s) for admin review", flagged)
}

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