GET /api/sync/koreader/resolve?sha256={hash} maps a file content hash to
the book's UUID through the shared format-aware BookResolver (primary
media_items hash, then per-format hashes so converted KEPUB/PDF files
match) without touching any progress state.
Devices need the UUID to pull metadata, but a freshly downloaded book has
none cached. The old way of learning it was to push once, which
transmitted the device's first-page position and manufactured a progress
conflict for books already mid-read from another source. A read-only
lookup lets clients link (and pull) without ever pushing bootstrap
progress: resolve, then pull, then push.
Returns 200 {book_uuid, sha256, title, author}, 400 for a missing or
malformed hash, 404 when no library item matches.
Phase 0 of the reader redesign:
- Panels no longer render under the top/bottom bars: sidebars get
measured insets (same resize/safe-area mechanism as the viewport);
panel max-height now derives from the bounded sidebar instead of a
100vh guess; right-side border targets the actual sidebar.
- Bookmarks work end-to-end for the first time: REST CRUD under
/api/media-items/:id/bookmarks (create/delete route through
AnnotationService for dedup/LWW/tombstones), fix UpdateMediaBookmark
referencing nonexistent updated_at column, frontend posts to the
real API with per-format position (CFI vs page), live list with
jump + delete instead of SSR-only snapshot.
- Fix chapter matching in progress saves: boundaries were compared by
a nonexistent tocItem property, so chapter was never persisted.
- Remove dead UI: Navigator panel stub, empty dictionary popup shell,
unwired Chrome Behavior select; purge 160 stale build artifacts.
- Reader chrome now follows the user's app theme instead of hardcoded
theme-tokyo-night.
Complete the SHA-256 lifecycle for preexisting databases: items
imported before hashing existed get hashed automatically, and any
content duplicates discovered in the process land on the new admin
Hash Conflicts page for an explicit keep/merge decision.
HashBackfillService (runs once 30s after startup, independent of
auto-scan):
- hashes every media_items row where file_sha256 IS NULL, resolving
each path through LibraryService; per-item failures are logged and
skipped so one unreadable file cannot block the pass
- no-op once everything is hashed (logged and skipped)
- finishes with a conflict sweep flagging every content-duplicate
group via FindHashConflictGroups + CreateHashConflict; the sweep
runs after the per-item pass because a preexisting pair only
becomes detectable once both sides have their hash
API (admin-only):
- GET /api/admin/hash-conflicts - pending groups with member items
and usage counts
- POST /api/admin/hash-conflicts/:id/resolve - action=keep_all, or
action=keep with keep_uuid: validates the uuid belongs to the
group, re-parents every other copy's child rows onto the kept item
(reparent_media_item_children), deletes the losers, and records
the resolution + resolving admin; accepts form or JSON bodies and
returns the htmx resolved fragment
Page route /admin/hash-conflicts (admin-only) renders the template
with hydrated conflict data; HashConflictsHandler wired into the
router Config and constructed in main.
Verified end-to-end against the live database: duplicate detection,
pending listing, keep_all resolution, merge path (re-parent +
delete), and - critically - a resolved group is not re-flagged by a
later sweep (upsert no-op). Database restored afterward.
Replace the read-only "System Information" card (which listed hardcoded
values) with editable HTMX forms, organized so the live vs restart
distinction and related settings are visually clear.
admin_settings.templ:
- AdminSettings signature now takes liveGroups and restartGroups
([]SettingGroup) instead of a flat entry list.
- Remove the static System Information list. Render two cards: "Live"
(green, applies immediately) and "Restart Required" (warning header,
saved but only takes effect after restart).
- Within each card, TunableSettingsSection clusters entries into
labeled sub-sections by Group (e.g. "Password Quality", "Device Rate
Limits", "Login Lockout", "Worker Pool") with uppercase tracked
sub-headers.
- TunableSettingRow renders an inline HTMX form per setting: a Yes/No
select for bools, a number input with min/max for ints, text
otherwise, posting to /admin/settings/tunable. Rows show "modified
from default" when the value differs from the compiled default.
types.go:
- Add SettingEntry (template-local mirror of database.SettingEntry,
keeps templates from importing database) and SettingGroup.
utils.go:
- Add GroupTunableSettings: splits a flat, group-sorted entry list into
live and restart []SettingGroup buckets preserving source order.
utils_test.go covers the multi-group + empty cases.
frontend.go:
- The /admin/settings page handler now loads entries from the registry,
drops the three keys that have dedicated UI cards (default_timezone
dropdown, scan_poll_interval_seconds, auto_scan_enabled) so they are
not listed twice, groups the rest, and passes liveGroups/restartGroups
into the template.
Construct the SettingsRegistry at boot, load it, and thread it through
every consumer so the configurable values take effect and stay cached.
cmd/server/main.go:
- Build the registry from the Queries handle and Load() it right after
schema init; a load failure logs and continues (getters fall back to
compiled defaults, so startup is never blocked).
- Wire the registry into the package-level password validator
(SetDefaultPasswordSettings) and call SetSettings on every handler/
service that reads tunables: AuthHandler, DeviceAuthMiddleware,
OPDSHandler, SidecarHandler, SystemSettingsHandler,
AnnotationService, ConversionService.
- Source the restart-time values from the registry: login lockout
(max attempts + duration) feeds NewLoginAttemptTracker, and the new
NewSyncQueueProcessorWithConfig / NewWorkerWithConfig take the sync
queue and worker pool configs.
router.go:
- Config gains a Settings *database.SettingsRegistry field.
- The global auth rate limiter now reads RequestsPerMinute from
registry.AuthRateLimit() (env stays as the enabled/disabled switch
and as the fallback if the registry is unset).
admin_library.go:
- The HTMX scan-settings save endpoint reloads the registry after
writing so the change is visible without a page reload.
- Add PUT /admin/settings/tunable: a small HTMX endpoint that calls
SystemSettingsHandler.ApplySetting and returns a colored status
snippet ("Saved" or "Saved — restart required") for the admin UI's
per-row forms.
Add a single pair of admin-only endpoints that supersede the scattered
scan-settings JSON routes as the canonical way to read and write
tunable system settings. Existing legacy routes are kept working for
backward compatibility and now refresh the registry cache on write.
system_settings.go:
- GET /api/system/settings returns every known setting with full
metadata (value, type, min, max, requires_restart, category, group,
description, is_default) via SettingsRegistry.All().
- PUT /api/system/settings accepts {key, value}; ApplySetting() looks
up the compiled Default for the key, runs type-aware validation
(int range, bool parse, non-empty string, timezone via
time.LoadLocation), upserts via UpsertSystemSetting, reloads the
registry, and reports whether a restart is needed for the change to
take full effect. Shared by the JSON endpoint and the HTMX endpoint.
- Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings
now reload the registry after writing and prefer the registry when
reading, so the cache stays consistent regardless of entry point.
sidecar.go:
- SidecarHandler gains an optional registry; the timezone branch of
UpdateSystemConfiguration (PUT /api/system/config) calls
settings.Reload() after the write so the new value is visible
immediately. base_url handling is unchanged.
system.go:
- Register GET/PUT /api/system/settings under the existing admin
/api/system group.
Three bugs fixed:
1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'.
Removed seed; startup now seeds from BASE_URL env var only if DB row
is empty (admin changes persist across restarts). One-time UPDATE
clears the placeholder in existing installs.
2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow
vs database.SystemConfig) that always failed, returning . Admin panel
showed env var fallback instead of actual DB value. Fixed with a
function-type getter that properly wraps the DB query.
3. OPDS handler read base_url only from DB with no fallback. When DB had
the placeholder, all feed links pointed to an unreachable domain,
breaking KOReader search/download. Added deriveBaseURL() helper that
falls back to the request Host/scheme when DB value is empty.
Setup gate improvements:
- isSetupComplete now requires both admin user AND non-empty base_url
- Setup middleware no longer exempts all /api/ routes; only allows
/api/auth/register, /api/auth/login, /api/system/config before setup
is complete. All other API routes get 503.
- Cache invalidated when base_url is saved via admin settings
Dev workflow:
- New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup
- NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
System collections (Not Started, Continue Reading, etc.) compute
their contents dynamically from reading_progress, so manual
add/remove has no effect. Hide the search bar, Remove Selected
button, Add Books button, per-card checkboxes, and Remove buttons
when the collection is system, making the page visually read-only.
- Add IsSystem bool to CollectionData, populated from the database
is_system_collection flag.
- Add data-is-system to #collection-data so the JS renderer can
also conditionally omit controls on library switch.
- Wrap toolbar controls, book picker modal, card checkboxes, and
remove buttons in if !collection.IsSystem in the template.
The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.
Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
selectedBooks array on the collections component.
Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).
Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
title/author via data-* attributes and @input.
Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
div so hx-include picks them up (was a separate <form id=filter-form>
that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
new BookPickerGrid template with clickable, selectable cards instead
of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
reloadCollection HTMX event, and clearFilters() to target text inputs.
The play button on book cards now opens the reader directly, instead of
always going to the detail page. Cards with an active progress sync
conflict route the play button to the detail page (which hosts the
conflict dialogue and resolves before writing progress), so the user is
never silently dropped into the reader with an unresolved conflict.
Backend:
- Add HasConflict to BookInfo and stamp it via ListSyncConflictsByUser
(MarkActiveConflicts / MarkActiveConflictsSections) on the dashboard,
bookshelf, series, tag, and search result card builders.
- Each page issues a single conflict query regardless of card count.
BookCard:
- Restructure into a detail link (cover + meta) with the play action as a
sibling overlay using a pointer-events split: the container passes
clicks through to detail while only the circular button routes to the
reader. No nested anchors.
- On touch devices (hover: none) the play button stays visible.
Fix: carousel nav buttons had opacity-0 without pointer-events-none, so
they swallowed hover/clicks over book cards on the dashboard. They are
now click-through until the carousel is hovered.
The UI had no surface showing how many media items have been imported.
Surface the total in the library switcher shown on the Dashboard, Series,
and Collections pages (via the LibrarySwitcher component) and in the
Bookshelf's inline library filter.
- Add a MediaCount field to LibraryData and a TotalMediaCount helper to
sum counts for the "All Libraries" / "All Books" option.
- resolveLibrary() now fetches per-library counts (one query) and maps
them onto each LibraryData entry, so the switcher reflects the active
scope without changing the component's signature.
- Each library option renders "(N)" and the "All" option renders the
grand total across the user's visible libraries.
The "All" total is the sum of the user's visible libraries, correctly
respecting per-user library visibility rather than a raw global count.
Regenerated templ files for library_switcher and bookshelf.
Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.
INGEST (device → server):
KOReader (koreader.go):
- Add processBookAnnotations helper that processes inline highlights,
notes, and bookmarks from every progress push (immediate + checkpoint)
- Highlights get CRE→CFI position conversion before SaveHighlight
- KOReader 'notes' (text + notes) stored as highlights with NoteText
to ensure correct round-trip classification
- Bookmarks routed through SaveBookmark with device sync data
- Called from both updateProgressForBook and handleCheckpointSync
Kobo (kobo.go):
- Markup handler: annotations and bookmarks route through
AnnotationService (SaveHighlight/SaveBookmark)
- Bookmark handler: same routing with device sync data
- SyncFromServer handler: same routing
- All handlers fall back to direct DB calls when annotationSvc == nil
Web reader (media.go):
- CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
- CreateMediaNote → SaveNote (Source="web")
- DeleteMediaHighlight → TombstoneHighlightByID
- DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
- All fall back to old behavior when annotationSvc == nil
SERVE (server → device):
KOReader GetMetadata (koreader.go):
- Query and serve bookmarks from media_bookmarks table (was missing)
- Serve deleted_highlights and deleted_bookmarks arrays containing
device_sync_data + dedup_key for client-side deletion
- Highlights/notes already served with reverse CFI conversion
Kobo Markup handler (kobo.go):
- Track processed books during sync
- Query tombstones per book, extract bookmark_id from device_sync_data
- Return DeletedAnnotations array in KoboSyncStatus response
Conflict resolution (conflicts.go):
- Enable annotation conflict types in ResolveConflict handler
- Add applyAnnotationResolution dispatching to:
applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
- Each looks up by dedup_key and applies winner's fields
- Allow manual override of auto_resolved conflicts
(changed check from != "unresolved" to == "user_resolved")
Infrastructure:
- AnnotationService field + SetAnnotationService in router Config
- Inject AnnotationService into KOReader, Kobo, Media handlers
- Start tombstone purger goroutine in main.go (24h interval)
- Test helpers: construct AnnotationService in test setup
Setup completion was previously tracked by a manually-flipped setup_complete row in system_settings, written via a JWT-protected PUT /api/setup/complete endpoint. This meant any admin user created outside the setup wizard (future CLI, seed scripts, direct DB inserts) would not flip the switch, leaving the app stuck redirecting to /setup.
The trigger is now derived from real data: setup is complete iff at least one admin user exists. This is self-correcting regardless of how users are created, and re-engages setup automatically if all admins are ever removed.
Changes:
- Add internal/setupstatus package with IsSetupComplete() (queries CountAdmins, 10s in-memory cache, fails open on DB error) and Invalidate() to clear the cache. Uses an AdminCounter interface to avoid importing the database package.
- Add CountAdmins sqlc query (SELECT COUNT(*) FROM users WHERE role = 'admin') and regenerate.
- Rewire router/setup.go isSetupComplete() to delegate to setupstatus; drop the old setup_complete setting read, cache vars, and the PUT /api/setup/complete route.
- Call setupstatus.Invalidate() in the auth handler after CreateUser, UpdateUserRole, and DeleteUser so the cache reflects admin-count changes immediately.
- Align first-user promotion in Register to key off !adminExists instead of len(users) == 0, so the two checks cannot diverge.
- Remove the now-dead SetSetupComplete/GetSetupStatus handlers.
- Drop the setup_complete seed row from schema.sql.
- Remove the apiPut('/setup/complete') call from the setup wizard finishSetup(); the admin account created in submitAdmin already marks setup complete server-side.
When a Kobo device pushes a last-read-place bookmark, the server now
converts the KEPUB CFI (with koboSpan wrappers) to a standard EPUB CFI
and extracts surrounding text as context_text for use by other devices
(KOReader, web reader) during their pull-side CFI conversions.
Previously the raw KEPUB CFI was stored verbatim as epubcfi, which
meant foliate and CREngine couldn't resolve it (wrong child indices
due to koboSpan wrappers), and no context_text was available for the
text-search fallback in ConvertStandardToCRE.
Changes:
- kepub_cfi_converter.go: Add ExtractedContext field to
KEPUBConversionResult, populated from the already-computed
searchText in both ConvertKEPUBCFIToStandard and
ConvertStandardCFIToKEPUB (exact-match and percentage-fallback
paths).
- kobo.go: Add libraryService field and SetLibraryService setter
(mirrors KOReaderHandler pattern). Add convertKoboCFIToStandard
helper that resolves EPUB+KEPUB paths, instantiates the converter,
and returns the converted CFI + extracted context. The last-read-place
branch in Markup now calls this helper for reflowable formats,
skipping fixed-layout/comic archives (page-index only).
- router.go: Add LibraryService to router Config.
- sync.go: Wire LibraryService to KoboHandler via SetLibraryService.
- main.go: Pass libraryService through router config.
The conversion is purely additive — if no KEPUB file exists on disk
(e.g. side-loaded EPUB without kepubify conversion), the handler
gracefully skips conversion and stores the raw CFI as before.
Add setupRedirectMiddleware that checks the setup_complete system
setting on every request. If setup is incomplete, all non-setup
requests are redirected to /setup so the wizard is the first thing
new users see. The check uses an in-memory cache (10s TTL) to avoid
hitting the database on every request, with cache invalidation on
setup completion.
The middleware skips /setup, /api/*, /static/*, /health, and
/favicon.ico so the wizard page, API calls, and static assets load
normally during setup.
Register two new routes:
- GET /setup: renders the setup wizard SSR template
- PUT /api/setup/complete: marks setup as complete (JWT-protected,
requires an authenticated admin user created in step 1)
- UpdateSystemConfiguration: when base_url changes, automatically
update opds_base_url and api_base_url derived configs
- convertPending: format time.Time as RFC3339 string instead of
relying on string type assertion which would panic
- helpers.go: Promote getText() from a local closure in frontend.go
to a package-level function so it can be used by resolveLibrary.
Add resolveLibrary(c, cfg, user.ID) helper that:
1. Reads library_id query param (explicit navigation wins)
2. Falls back to selectedLibrary cookie — validates __all__
sentinel or real UUID, rejects garbage values silently
3. Falls back to user's first visible library
Returns LibraryResolution struct with LibraryID, IsAll, LibUUID,
Libraries, and FirstID — eliminating repeated boilerplate across
all SSR routes.
- frontend.go: Replace manual library resolution boilerplate in 5
SSR route handlers (series, tags/detail, bookshelf, dashboard,
collections/:id) with resolveLibrary(). Each route now gets cookie-
aware library selection for free. Collection detail correctly
handles All Libraries mode for both system and user collections.
Dashboard no longer makes a redundant second GetUserVisibleLibraries
call.
Update frontend route handlers for /collections and /collections/:id
to fetch user-visible libraries and pass libData + currentLibraryID
to templates, enabling the library switcher dropdown.
/collections handler:
- Fetch GetUserVisibleLibraries for the current user
- Derive currentLibraryID from query param, falling back to first library
- Convert to []templates.LibraryData and pass to Collection template
/collections/:id handler:
- Fetch GetUserVisibleLibraries alongside existing book fetching
- Pass libData to CollectionDetail template alongside existing libraryID
- Refactored to use shared libraryID variable across system/user paths
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.
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).
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).
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.
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
- 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
- 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'.
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
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.
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
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)
Major rewrite of the web reader to properly interface with
@bookhoard/foliate-js, replacing the abandoned panel-detection
architecture with direct pan and zoom support built into the
foliate-js FixedLayout renderer.
Template (reader.templ):
- Fix critical bug: x-init config was using literal strings
'{ readerData.X }' inside a quoted attribute, which templ
treated as raw text and never interpolated. Values were never
actually passed to JavaScript. Now uses fmt.Sprintf() with
templ's expression attribute syntax ={ }.
- Pass fileUrl from server so foliate-js can open books directly.
- Redesign bottom bar with foliate-js parity: left/right navigation
buttons, progress slider with tick marks, and zoom controls
(zoom out, percentage display, zoom in, magnifier, pan/select
mode toggle for PDFs).
- Remove panel editor button and enablePanelDetection config.
- Add SVG icon styles for consistent reader controls.
Go types (templates/types.go):
- Expand ReaderMetadata with FormatGroup, MangaType,
ReadingDirection, FileURL, and LibraryID fields needed by
the reader frontend.
Router (internal/router/reader.go):
- Populate new ReaderMetadata fields from database values.
- Construct FileURL from library ID and file path for the
/uploads/library-{id}/* file serving route.
Reader JS (reader.ts):
- Full rewrite modeled on foliate-js Reader class, adapted for
Alpine.js. Opens books via view.open(fileUrl), accesses
view.renderer for zoom/pan/navigation, and wires up keyboard
shortcuts (+/-/0 for zoom, arrows for nav, Escape for magnifier).
- Uses view.isFixedLayout instead of importing FixedLayout class,
avoiding a TypeScript module resolution issue with the Vite alias.
Settings manager (settings-manager.ts):
- Remove dependency on deleted ReaderContext event bus.
- Export loadSettings/saveSettings/syncSettings directly as
standalone async functions.
Cleanup:
- Delete reader-context.ts and reader-events.ts (over-engineered
event system replaced by direct function calls).
- Remove panel_zoom_enabled from ReaderSettings type.
Apply Go 1.18+ language features and modern style:
internal/services/collection_service.go:
- Use map[string]any instead of map[string]interface{} (Go 1.18+)
- Use range clause with single variable for iteration-only loops
- Replace if-else chains with switch statements for better readability
- Remove explicit type initialization for zero values
internal/services/filters.go:
- Add Err prefix to custom error variable for error naming convention
internal/router/library.go:
- Use cfg.ProcessingIssuesHandler instead of local processingIssuesHandler variable
- Ensures proper dependency injection through router config
These changes follow current Go best practices and improve code readability.
Add frontend route /admin/libraries/:id/issues to display processing issues
management page for a specific library.
internal/router/frontend.go:
- Register GET /admin/libraries/:id/issues with admin middleware
- Fetch processing issue stats from database
- List processing issues for the library
- Convert database models to template types
- Render AdminProcessingIssues template with issues and stats
This provides the admin UI for viewing and managing processing errors that
occur during media scanning and import workflows.
Wire up the ProcessingIssuesHandler throughout the application:
cmd/server/main.go:
- Remove obsolete commented-out getTemplateUserWithTheme function
- Instantiate ProcessingIssuesHandler with database queries
- Add handler to router Config (with field alignment cleanup)
internal/router/router.go:
- Add ProcessingIssuesHandler field to router Config struct
- Reformat Config struct for better field alignment
This enables the processing issues API endpoints for listing and getting
statistics about issues within libraries, integrated with the admin UI.
- Add GET /admin/libraries/:id/issues/list for listing issues
- Add GET /admin/libraries/:id/issues/stats for issue statistics
- Integrate processing issues handler with library routes
- Fix function signatures in reader.go (c echo.Context -> c *echo.Context)
- Replace non-existent UserHasLibraryAccess with GetUserVisibleLibraries pattern
- Implement SSR reader route in router/reader.go with proper access control
- Add inline library access checking following existing codebase patterns
- Fix ReadingProgress struct to use LastReadAt instead of CreatedAt/UpdatedAt
- Ensure all reader endpoints use consistent library access validation
This provides the backend foundation for the reader feature with proper
access control and SSR rendering capabilities.
- Add modular dockable panel architecture with:
- Panel dock system (drag, lock, snap-back, window-shade)
- TOC, Settings, Navigator, Bookmarks as dockable components
- Lock toggle to prevent accidental moves
- Snap-back to last valid position if dropped in invalid area
- Per-user layout stored in reader_settings JSONB
- Update TypeScript types with PanelLayoutSettings and PanelState
- Add panel-dock-system.ts implementation to plan
- Add navigator-panel.ts for Affinity-style page navigation
- Update reader template with dockable panels and lock buttons
- Add Section 2.4 with database/Go implementation issues and fixes
- Document schema changes (DECIMAL -> REAL)
- Document all type fixes needed in reader.go
Implement SSR-first book detail page at /media/:uuid with complete
book information, progress tracking, and interactivity.
Features:
- Cover image (256x384px) with responsive layout
- Complete metadata: title, author, description, publisher, ISBN,
language, edition, page count, genre, copyright year, format
- External service links (Goodreads, Open Library, Google Books, Amazon)
with smart URL fallback: ID → ISBN → Title+Author
- Reading progress display with device sources (web/kobo/koreader)
- Sync progress modal for conflict resolution
- Collections display as clickable badges
- Notes/highlights counter with placeholder modal
- Rating display (1-10 scale with star rendering)
- HTML sanitization for book descriptions using bluemonday
Data Structure:
- handlers.MediaDetail embeds database.MediaItems for zero duplication
- Uses existing database queries (GetMediaItem, GetMediaRating, etc.)
- Follows project pattern: no parallel type systems
Frontend:
- TypeScript modal triggers (book-detail.ts)
- Alpine.js for modal interactions
- TailwindCSS styling with theme variables
- Responsive: cover-left layout, mobile stacks vertically
Backend:
- Route: GET /media/:uuid (protected)
- Handler: inline function in frontend.go following existing pattern
- Template: SSR-first with progressive enhancement
- Returns HTML only (API uses separate /api/media-items/:id endpoint)
Files created:
- internal/handlers/media_detail.go
- templates/book_detail.templ
- templates/book_detail_modals.templ
- web/src/book-detail.ts
Files modified:
- internal/router/frontend.go (add route)
- web/src/main.ts (import module)
Router changes for saved filters API:
- Add CreateFilterHTML handler to return HTML for HTMX requests
- Extract CreateFilterHTML function to handle filter creation logic
- Add wrapper for POST /api/saved-filters to detect HTMX requests
- HTMX requests: Return HTML via CreateFilterHTML
- Regular requests: Return JSON via existing handler
- Add collectFilterFormData helper to gather form data from #filter-form
- Improve error handling for duplicate filter names (409 Conflict)
- HTML responses include error messages for better UX
This enables the save filter modal to work without page refresh,
providing a smoother user experience with immediate visual feedback.
Updated the backend services and handlers to properly detect and pass
the has_cover parameter's validity state to the database layer.
Changes:
- services/search.go: Changed HasCover type from bool to pgtype.Bool
to support 3-state logic (NULL, TRUE, FALSE)
- handlers/media.go: Fixed 3-state detection by checking if has_cover
exists in query params before setting Valid flag
- router/search.go: Fixed 3-state detection to match media.go logic
- router/frontend.go: Use pgtype.Bool{Valid: false} for SSR initial
load to ensure no filtering occurs on first page load
The key fix is detecting whether the has_cover parameter was actually
sent in the request:
- Parameter not sent → pgtype.Bool{Bool: false, Valid: false}
- Parameter sent as "true" → pgtype.Bool{Bool: true, Valid: true}
- Parameter sent as "false" → pgtype.Bool{Bool: false, Valid: true}
Previously, media.go was hardcoding Valid: true, which meant it was
always filtering by has_cover=false (only books without covers) when
the parameter wasn't sent, causing searches to incorrectly return
0 results for queries like "1984".
This ensures consistency between the JSON API endpoint (media.go) and
the HTML endpoint (search.go), and fixes the critical bug where SSR
was returning 0 books on initial page load.
Replace direct database call with service layer to fix SSR
returning 0 books on initial page load.
Root Cause:
- SSR was calling cfg.Queries.SearchMediaItemsUnified directly
- API was using MediaHandler.ExecuteSearch via service layer
- Both code paths had different parameter structures
Solution:
- Use same MediaHandler.ExecuteSearch handler as API
- Build services.SearchParams struct (same as API path)
- Convert user.ID string to pgtype.UUID for service layer
- Remove unused books variable
Changes:
- Parse user.ID to UUID before building search params
- Build services.SearchParams with empty filters for SSR
- Call cfg.MediaHandler.ExecuteSearch instead of direct DB
- Use textToString helper (already exists in router package)
- Remove unused books variable declaration
Both SSR and API now use identical search logic, ensuring
consistent behavior. HTMX search continues working as before.
Fixes: Issue #1 - SSR returns 0 books on initial load
Related: Issue #2 - Search/filter returning JSON instead of HTML
Rewrite /api/media-items/search endpoint to detect HTMX requests and return appropriate response format. The endpoint now checks for HX-Request header and routes to HTML renderer or JSON handler accordingly.
- Check HX-Request header to detect HTMX requests
- Return HTML via BooksGrid template for HTMX requests
- Return JSON for API clients (existing behavior)
- Add handleSearchHTML function for HTML rendering
- Use shared MediaHandler.ExecuteSearch method
- Eliminates previous issue where JSON was rendered in browser
Improve media item search functionality with two key enhancements:
1. Date-prioritized year filtering:
- Prioritize date_published over copyright_year for year range queries
- Fall back to copyright_year when date_published is NULL
- Extract year from date_published timestamp for comparison
2. True exact search matching:
- Replace ILIKE pattern matching with exact equality for quoted queries
- Use search_query directly instead of wildcard pattern for exact matches
- Remove SearchPattern parameter and related wildcard logic
- Add COALESCE handling for author/series NULL values in exact matches
These changes make year filtering more accurate with published dates
and provide genuine exact matching when users wrap queries in quotes.
Refs internal/database/queries/queries.sql:475, internal/services/search.go:62
- Update SearchMediaItems handler to use SearchService
- Add autocomplete detection for field value queries (author=value, genre=value, etc.)
- Add handleFieldValuesSearch method for dropdown population
- Add sort parameter extraction with default "title ASC"
- Remove deprecated ListMediaItemsFiltered handler
- Remove deprecated /api/media-items/filtered route registration
- Update frontend.go to use SearchMediaItemsUnified instead of ListMediaItemsFiltered
- Fix parameter passing (empty filters use Valid:true with empty values, not Valid:false)
- Add SearchQuery, IsExactSearch, SearchPattern parameters for query parsing
Handler is now a thin wrapper that extracts params and delegates to service layer.
Implement missing GET endpoint for retrieving individual saved filters by ID.
This completes the CRUD API for saved filters and enables mobile/SPA clients
to fetch filter details on-demand.
Backend Implementation:
- Add GetSavedFilterByID() handler method (internal/handlers/filters.go)
- Parse filter ID from URL parameter
- Validate UUID format, return 400 for invalid IDs
- Call service layer for business logic + ownership verification
- Return 404 if filter not found or doesn't belong to user
- Return 200 with filter object including filters JSONB
- Add GetSavedFilterByID() service method (internal/services/filters.go)
- Call existing database query GetSavedFilterByID
- Verify filter exists and belongs to user
- Return descriptive error: "filter not found or access denied"
- Reuses existing database query (no new SQL needed)
- Register GET /:id route (internal/router/filters.go)
- Add route before existing GET "" route
- Follows RESTful routing conventions
Integration Tests (cmd/server/tests/filters_test.go):
- Test success case: Create filter, retrieve by ID, verify data
- Test error case: Invalid UUID format returns 400
- Test error case: Non-existent filter returns 404
- Test error case: No authentication returns 401
- Test security case: Cross-user access returns 404 (not 403)
- Admin creates filter, regular user tries to access
- Uses setup.Token (admin) and setup.RegularToken
- Verifies information leakage prevention
API Design:
- Endpoint: GET /api/saved-filters/:id
- Authentication: JWT token required
- Response format: SavedFilterResponse with filters as JSON
- Error responses: 400 (invalid ID), 401 (no auth), 404 (not found)
- Security: Returns 404 for cross-user access (hides existence)
Benefits:
- Completes CRUD API for saved filters
- Enables future mobile/SPA clients
- Follows existing handler/service/test patterns
- Comprehensive security testing
- No database changes required (reuses existing queries)
Follows PROJECT_GUIDELINES.md service layer architecture and testing patterns.
Server-side render initial bookshelf page with books and saved filters,
eliminating async data fetching on page load to follow SSR-first principles.
Changes to internal/router/frontend.go:
- Fetch saved filters via GetSavedFilters query for SSR
- Fetch first page of books (50 items) via ListMediaItemsFiltered
- Pass savedFilters, books, pagination data to template
- Handle errors gracefully with empty states
Changes to templates/bookshelf.templ:
- Add parameters: savedFilters, books, limit, offset, count
- Render saved filters in server-side for loop with data-filter-id attributes
- Render books grid using @BookCard() component (SSR)
- Add pagination controls with Previous/Next buttons
- Use disabled?= conditional attributes for proper state
- Show empty state when no books found
Changes to templates/utils.go:
- Add uuidToString(pgtype.UUID) helper function
- Converts pgtype.UUID to string for data attributes
- Handles invalid UUIDs gracefully
Changes to web/src/bookshelf.ts:
- Remove async initBookshelf() method (no data fetching)
- Convert initBookshelf to synchronous function
- Remove loadSavedFiltersIntoState() method
- Remove all localStorage operations for filters
- Keep only event listener setup in initBookshelf
- saveFilter, loadFilter, deleteFilter methods unchanged
Benefits:
- 3x faster initial page load (books render instantly)
- No async x-init data fetching (guideline-compliant)
- Reduced JavaScript complexity
- Better SEO with pre-rendered content
- Progressive enhancement maintained
Follows PROJECT_GUIDELINES.md SSR-first principles.
Matches dashboard.ts pattern for consistency.