Adds .gitea/workflows/release.yml. On a v* git tag push (or manual dispatch), builds the Dockerfile and publishes to git.linuxhg.com/bookhoard/bookhoard under two tags: the version (${{ gitea.ref_name }}) and 'latest'. Auth uses the auto-provided GITHUB_TOKEN; no secret to manage. Pushes to main do nothing, so work-in-progress commits never ship.
Expose the recently-added env-driven compose settings as commented examples so self-hosters and deployers can discover them. All remain optional with defaults.
Replace hardcoded port literals with env-driven variables so a single change
in .env reconfigures the full stack consistently. Defaults are unchanged
(DB 5432, app 8765), so existing setups need no .env changes.
- DB_PORT (default 5432): drives the db host<->container port mapping,
Postgres PGPORT (so it listens on the chosen port), and the app's
DATABASE_PORT connection setting. Lets deployers avoid a host port conflict
(e.g. another local Postgres) by setting DB_PORT once.
- SERVER_PORT (default 8765): drives the app host<->container mapping, the
SERVER_PORT the app listens on, and the healthcheck target URL.
- Applied to both the base (docker-compose.yml) and the dev override
(docker-compose.dev.yml, tests service) so dev and prod stay in sync.
Address compose issues surfaced on first production deploy:
- Remove obsolete `version: "3.8"` (ignored by Compose v2; caused a warning).
- Fix BASE_URL: it used compose-time interpolation of ${SERVER_PORT}, which is
only defined as a runtime container env var (invisible to interpolation) and
absent from .env. This resolved to an empty string, producing a broken
`http://localhost:` (no port) and a startup warning. Now
${BASE_URL:-http://localhost:8765}, overridable per-deployment via .env.
- Move COOKIE_SECURE from the db service to the app service and make it
configurable (${COOKIE_SECURE:-false}). It controls the session cookie Secure
flag, an app concern; on the db service it was a no-op, so the app never
received it and cookies were always non-secure. Set COOKIE_SECURE=true behind
a TLS-terminating reverse proxy (Caddy/nginx/traefik), where the app speaks
plain HTTP internally.
- Image reference unchanged: ${IMAGE_TAG:-latest} (no hardcoded version).
Restructure the container setup to support registry-based deployment:
the default docker-compose.yml now pulls a prebuilt app image from the
Gitea container registry instead of building locally, while a new
docker-compose.dev.yml override preserves the local build + integration
test workflow for development.
Why:
- Production and self-hosting should consume a published image, not
rebuild from source on the host. The default `docker compose up` now
pulls the app image (git.linuxhg.com/bookhoard/bookhoard) alongside the
public postgres image, with no build step required.
- Development still needs to build from source and run integration
tests, so those concerns move to an override file the Makefile applies.
Shared config (env, volumes, ports, healthchecks) lives in one place to
avoid drift between environments.
Changes:
- docker-compose.yml (prod base): the app service now references
`image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}` instead
of a build context. The tests service is removed (moved to the
override). IMAGE_TAG lets deployers pin or roll back a specific version.
- docker-compose.dev.yml (new override): adds the local `build:` context
for the app and defines the integration `tests` service (profile-gated).
Everything else is inherited from the base file via compose merging.
- Makefile: introduce a COMPOSE variable that merges the base and
override (`-f docker-compose.yml -f docker-compose.dev.yml`); all dev
targets now use it. Plain `docker compose` against the base file only
remains the production path.
- README: quickstart updated to pull and start prebuilt images; clone URL
points at the Gitea instance.
The development workflow (`make rebuild-app`, `make test-integration`,
etc.) is functionally unchanged.
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
Wire AnnotationService into SyncQueueProcessor and implement the three
previously-stubbed execute methods:
- syncHighlight: unmarshals syncData JSON into SaveHighlightRequest,
applies CRE→CFI conversion via AnnotationService
- syncNote: unmarshals into SaveNoteRequest
- syncBookmark: unmarshals into SaveBookmarkRequest
- Add SyncTypeBookmark to executeSync switch (was hitting default error)
Add enqueue methods for future offline/batch use:
- EnqueueHighlight / EnqueueNote / EnqueueBookmark
- Shared enqueueAnnotation helper creates queue items with
PriorityCriticalNote and 3 max attempts
- Update types (HighlightUpdate, NoteUpdate, BookmarkUpdate) mirror the
existing ProgressUpdate pattern
Existing handler behavior is unchanged — annotations still sync
synchronously via AnnotationService. The queue path is available for
retry-on-failure and offline batch processing scenarios.
AnnotationService is the central service for cross-device annotation sync.
It provides SaveHighlight, SaveNote, and SaveBookmark methods that handle
the full sync lifecycle:
Identity (3-layer):
1. Server UUID (primary key)
2. Per-device native ID stored in device_sync_data JSONB
3. Content dedup_key: sha1(normalize(selection_text) + bucket_position)
- CFI character offsets are stripped for bucketing so the same
highlight at slightly different offsets still deduplicates
- Raw positions are preserved in the DB for precise restoration
Resolution policy (LWW):
- When the incoming annotation has an explicit ModifiedAt timestamp,
last_modified_at wins
- When the device sends zero ModifiedAt (creation time only), field-diff
mode compares content fields (text/color/note/percentage) — if all
match, the save is skipped; if any differ, the save is applied with
server-receive-time as the new last_modified_at
Conflict detection:
- When incoming and existing annotations have different sources (e.g.
koreader vs kobo) and content differs, an auto_resolved sync_conflict
is recorded with both sides' data for audit trail
- Broadcasts a WebSocket conflict notification for real-time UI updates
Tombstone management:
- Delete-wins: tombstoned annotations block recreation from stale pushes
- 30-day TTL before physical purge
- PurgeExpiredTombstones method + StartTombstonePurger goroutine (24h ticker)
Add locators.go with unified bidirectional CFI conversion:
ConvertToCanonical / ConvertFromCanonical
- CRE XPointer <-> standard EPUB CFI (for KOReader)
- KEPUB CFI passthrough (for Kobo)
- Skips non-reflowable formats (PDF, CBZ, fixed-layout EPUBs)
Add 25 unit tests covering:
- Dedup key determinism, text normalization, position sensitivity
- Offset insensitivity (CFI char-offset bucketing)
- Device sync data merge (preserves existing, overwrites same source)
- Cross-source detection
- LWW comparison (newer wins, older skipped, fallback to updated_at)
- Field-diff mode (identical content skipped, changes applied)
- Tombstone TTL constant
- CRE XPointer parsing and classification
- Standard EPUB CFI classification
Add migration columns to media_highlights, media_notes, and media_bookmarks
for cross-device annotation sync:
- dedup_key: SHA-1 of normalized selection text + bucketed position, used
as the stable cross-device identity for annotations
- last_modified_at / last_modified_source: edit clock for LWW resolution
and cross-source conflict detection
- deleted / deleted_at: sticky tombstone columns for delete-wins semantics
with a 30-day TTL before rows are physically purged
- note_text on highlights: stores attached notes from KOReader entries that
have both selected text and a user note
- Location columns on bookmarks (cfi_position, percentage_location,
epubcfi_location, chapter_reference, paragraph_reference)
- device_sync_data JSONB on all three tables: stores per-device native
identifiers (e.g. KOReader pos0/datetime, Kobo bookmark_id) so each
device can locate and manipulate its own copy of an annotation
Add partial unique indexes on (user_id, media_item_id, dedup_key) where
deleted = FALSE to enforce one active annotation per dedup key.
Add tombstone purge indexes on (deleted, deleted_at) for efficient GC.
New queries:
- GetByDedupKey for all three tables (returns active or most-recent tombstone)
- CreateFull / UpdateForSync for all three tables (populate sync columns)
- TombstoneByDedupKey / TombstoneByID for all three tables
- PurgeExpired* for all three tables (GC past TTL)
- GetActiveAnnotationsForBook (filtered union of highlights + notes)
- GetTombstonedAnnotationsForBook (union of all 3 deleted within TTL)
- GetMediaBookmarks with deleted filter
- GetMediaBookmark (singular) with deleted filter
- Added deleted=FALSE filter to GetMediaHighlights, GetAnnotationsForBook
- CreateAutoResolvedSyncConflict (INSERT with resolution_status='auto_resolved')
Quote DATABASE_PORT and SERVER_PORT ("5432", "8765") in docker-compose.yml so they are treated as strings rather than YAML integers, avoiding type-coercion warnings from compose runtimes.
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.
The btn-secondary class was used 39 times across 15 templates but had
no definition in any global CSS file. The only definition existed in
error.templ's inline styles (intentionally self-contained).
Added .btn-secondary and .btn-secondary:hover to input.css using
theme-aware CSS variables (--accent) consistent with the existing
.btn-primary pattern. Rebuilt style.css via Tailwind.
The Manga Type and Reading Direction select options used invalid inline
if syntax: 'if cond { selected }' which is not valid templ. Replaced
with the correct templ attribute syntax: selected={ boolExpression }
which properly renders the selected attribute when true and omits it
when false.
Also add for/id attributes to link labels to their select elements
for accessibility (manga_type and reading_direction).
Affected lines: book_detail_modals.templ:478-496
Remove TestConvertHessBook and TestDebugHess (both referenced a
non-existent Hess EPUB at an absolute local path) and
TestConvertCPByTextSearch (referenced a Crime and Punishment EPUB
in the local uploads directory). These were development-time debug
tests that only worked on the author's machine.
All CFI/KEPUB conversion behavior is already covered by the proper
fixture-based tests (TestKEPUBRoundTrip, TestKEPUBConvertKEPUBToStandard,
TestKEPUBConvertWithEmElements, etc.) which use createTestEPUB and
createTestKEPUB helpers.
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.
The web reader now captures ~100 chars of visible text from the
foliate relocate event's range and includes it as context_text in
the progress PUT body for reflowable formats.
This enables the server's reverseByTextSearch fallback in
ConvertStandardToCRE, which is critical for single-file EPUBs
(e.g. 1984) where foliate emits coarse or fake-section CFIs that
CREngine cannot directly resolve. Previously only the KOReader
plugin sent context_text; the web reader's omission left the
fallback unusable, causing percentage-based position estimation
that was off by ~1 page.
Changes:
- Add contextText state property (line 387)
- Extract visible text from e.detail.range in relocate handler,
normalize whitespace, and slice to 100 chars (lines 508-512)
- Include context_text in saveProgress PUT body for reflowable
EPUBs only, alongside epubcfi (line 575)
The server-side chain was already wired: media.go accepts it,
progress.go stores it, and koreader.go passes it to the CFI
converter. No Go changes needed.
The web reader's saveProgress always sent epubcfi: cfi || "", even
for fixed-layout comics/PDFs. Foliate's comic renderer generates a
fake CFI (epubcfi(/6/{n})) for every page via CFI.fake.fromIndex,
which the server stored as a valid locator. These fake CFIs are
meaningless — the page index is the canonical locator for image-based
content — and they caused koreader to crash on pull (see prior commit).
Only set epubcfi in the request body for reflowable documents.
The push path (koreader→server) was already updated to omit epubcfi
for paging documents, and SaveProgress derives percentage from
page/total_pages for fixed formats. However, the pull path
(server→koreader) still returned any stored epubcfi and attempted
CFI→XPointer conversion, and SaveProgress preserved stale CFI values
written by the web reader (which generates fake CFIs via
CFI.fake.fromIndex for every comic/PDF page).
These fake CFI strings lingered in the database and koreader's pull
path picked them up over progress.page, causing tonumber("epubcfi(...)")
→ nil → GotoPage(nil) → crash when the user confirmed the sync prompt.
Changes:
- GetMetadata (koreader.go): for fixed_layout/comic_archive formats,
skip returning epubcfi and skip the CFI→CRE XPointer conversion.
Reflowable documents are byte-for-byte unchanged.
- SaveProgress (progress.go): for fixed formats, explicitly clear
epubcfi and character_offset on every save so stale values from
prior web-reader sessions are cleaned up over time.
The CRE->CFI converter works by text search / character-offset mapping
across the EPUB spine. Image-based fixed content (fixed-layout comic
EPUBs, PDFs, comic archives) has no extractable text, so the conversion
can never succeed and only wastes time parsing content docs while
logging a failed percentage-precision result.
Guard the conversion in the KOReader progress handler: when the matched
media item's FormatGroup is fixed_layout or comic_archive, skip
ConvertCREToStandard entirely. The incoming xpointer is left as-is so
KOReader<->KOReader restore via GotoXPointer still works; the web reader
restores by page index (the canonical locator for these formats).
This covers fixed-layout comic EPUBs in particular: KOReader routes all
EPUBs through CREngine (has_pages == false), so they send a real
xpointer that passes IsCREXPointer and would otherwise trigger the
doomed text extraction. Reflowable EPUBs (format_group == reflowable)
still run the conversion exactly as before.
foliate's goLeft/goRight (and spread ordering) swap on book.dir ===
"rtl", but makeComicBook never sets dir, so manga/comic archives
always paged left-to-right even when the metadata says right-to-left.
For fixed-layout content, set this.book.dir = "rtl" from the
readingDirection metadata after the book opens. Reflowable EPUBs are
unaffected: they keep whatever direction foliate read from the OPF.
This is scoped to isFixedLayout (FXL EPUB, PDF, comics) so it cannot
reach the reflowable path.
Fixed-layout EPUBs, PDFs, DjVu, and comic archives (cbz/cbr/cb7/cbt)
are page-based: each page is a fixed image, so a page index is an exact,
universal locator regardless of screen size or device. Sync previously
treated these like reflowable content (CFI-first restore, percentage
fallback, character-offset math), which was both wrong and lossy. This
makes the page index the canonical position for fixed-layout and comic
formats while leaving the reflowable path byte-for-byte unchanged.
Backend:
- progress.go SaveProgress: branch on mediaItem.FormatGroup. For
fixed_layout/comic_archive derive percentage from current_page/
total_pages and skip the CFI/character-offset back-fills (meaningless
for image content). The reflowable derivation block is preserved
verbatim under an else.
- kobo.go: Kobo only sends a percentage, so for fixed-layout/comic
formats derive CurrentPage via PercentageToPage(percentage, pageCount)
using the media item's known page count, so Kobo->web lands on the
exact page.
Reader:
- reader.templ readerInitExpr: pass savedPage/savedTotalPages to the
reader config for fixed_layout/comic_archive formats.
- reader.ts: when isFixedLayout and savedPage is present, restore via
view.init({ lastLocation: savedPage - 1 }) (a bare number navigates
foliate directly to the section index). Reflowable falls through to
the existing CFI->percentage path, unchanged.
The OPDS device catalog previously hard-coded every acquisition link as
application/epub+zip and always offered kepub/pdf alternate links, which
is wrong for comic archives (cbz/cbr/cb7/cbt) and other non-epub media.
- Resolve the acquisition mime type from the media item's stored
mime_type (falling back to format_mimetype, then epub) instead of
assuming epub
- Only offer reflowable conversions (kepub for kobo, pdf) for ebooks;
comic archives are served as-is in their native format
- Derive the native format label (epub/pdf/cbz/cbr/...) from the file
path in ListFormats rather than always reporting epub
- Add resolveMimeType, isComicArchive, and formatLabelFromPath helpers
Add a Server URL field to the admin-account step of the setup wizard so
the public base URL (used for device sync, OPDS feed, and API endpoints)
is configured up front instead of requiring a later visit to admin
settings.
- setup.templ: base URL input on the admin step plus a summary entry
- setup.ts: default baseUrl to window.location.origin and persist it via
PUT /system/config ({ base_url }) right after the admin account is
created, with a non-fatal warning if the save fails
Add a single-page multi-step setup wizard that guides new users
through initial configuration:
Step 1 - Admin Registration: Creates the first user (auto-admin)
using the existing POST /api/auth/register endpoint, with
real-time password validation and confirmation matching.
Step 2 - Library Creation: Create one or more libraries (Ebooks,
Audiobooks, Comics, Manga) using POST /api/libraries. Libraries
list updates inline as they're added.
Step 3 - Folder Configuration: Add filesystem folders to each
library using the existing GET /api/libraries/browse endpoint for
a visual directory browser. Folders are attached via POST
/api/libraries/:id/folders.
Step 4 - Initial Scan: Triggers a manual scan of all libraries via
POST /api/libraries/scan with real-time progress polling using the
existing scan status endpoint.
On completion, the wizard calls PUT /api/setup/complete and sets the
selectedLibrary cookie to the first library's UUID, ensuring the
dashboard loads with populated content instead of an empty 'All
Libraries' view. Handles the edge case where a stale JWT from a
previous database instance triggers an 'already exists' error by
auto-advancing to step 2.
The wizard reuses all existing API calls, Alpine.js utilities, and
form validation functions — no backend logic was duplicated.
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)
Add 'setup_complete' boolean to the system_settings seed data (defaults
to false) so fresh databases start in the unconfigured state.
Add two new handlers to SystemSettingsHandler:
- SetSetupComplete: marks setup_complete=true in the database
- GetSetupStatus: reads the current setup_complete value, returns
{setup_complete: bool} JSON response, defaults to false if the
setting row is missing or unparseable
cfi_converter_test.go:
- TestFindTextInNode_SingleTextNode: baseline single-node match
- TestFindTextInNode_CrossEmElement: 'Vokalia and Consonantia' across
two <em> elements — verifies match returns the 'Vokalia' text node
- TestFindTextInNode_CrossStrongElement: text crossing <strong> boundary
- TestFindTextInNode_DoesNotCrossParagraphs: verifies block boundary
enforcement — text split across <p> elements is NOT matched
- TestFindTextInNode_NestedFormatting: <em><strong> nesting
- TestFindBlockParent: verifies findBlockParent walks up through inline
elements to find block-level <p>
- TestCollectInlineText: verifies text segments are collected in order
with correct content
kepub_cfi_converter_test.go:
- TestKEPUBConvertWithEmElements: previously skipped, now expects exact
precision and verifies round-trip conversion works for text spanning
<em> element boundaries
Instead of reading only from the single resolved text node (which
produces a tiny window when the position is inside <em> or other inline
elements), extractSurroundingText now:
1. Finds the block-level parent (e.g. <p>) of the resolved text node
2. Collects all text within that block, transparently crossing inline
formatting elements via collectInlineText
3. Computes the global offset of the original text node within the
concatenated block text
4. Extracts the [offset-window : offset+window] slice
This gives a full context window regardless of inline element
boundaries, enabling accurate text bridging between EPUB and KEPUB
documents even when reading positions fall inside <em>, <strong>,
<span class="koboSpan">, etc.
Falls back to single-node extraction when no block parent is found
(e.g. orphan text nodes in tests).
When finding or extracting text in EPUB/KEPUB DOM trees, inline
formatting elements like <em>, <strong>, <i>, <b>, <span>, etc. should
not break text continuity. A reader sees 'Vokalia and Consonantia' as
one phrase regardless of the <em> wrappers around each word.
Add inline formatting element set and helper functions:
- isInlineFormatting: checks if an element is an inline phrasing element
- collectInlineText: flattens text across formatting elements within
a block-level parent, returning segments that map back to original
text nodes
- findBlockParent: walks up from a text node to find the nearest
block-level ancestor (used to scope text collection)
- findTextAcrossInlineElements: fallback for findTextInNode that
concatenates text within each block element (transparently crossing
formatting elements) and maps match positions back to actual nodes
- collectBlockElements: gathers all block-level elements containing text
The key invariant: text collection NEVER crosses block-level element
boundaries (<p>, <div>, <h1>-<h6>, <li>, etc.) to avoid concatenating
text from different paragraphs.
The findTextInNode function now tries single-text-node matching first
(fast path, unchanged), then falls back to cross-element matching only
when needed. This preserves performance for the common case.
Test coverage for KEPUBCFIConverter with programmatically generated
EPUB and KEPUB zip fixtures:
- KEPUB->Standard text search conversion (exact precision)
- Standard->KEPUB text search conversion with round-trip verification
- Multi-position round-trip (3 phrases across different paragraphs)
- Cross-chapter conversion (spine index 1)
- No-context-text conversion (extracts surrounding text from resolved node)
- Invalid CFI handling (falls back to percentage)
- Percentage fallback for unresolvable CFIs
- 5-paragraph round-trip covering different document positions
- CFI structural difference verification (koboSpan adds DOM steps)
- Spine index consistency between EPUB and KEPUB
- Real book conversion test (skips if file not present)
- extractSurroundingText unit tests
Add KEPUBCFIConverter in internal/sync that converts between KEPUB CFIs
(which include extra koboSpan DOM steps) and standard EPUB CFIs at sync
time, so only standard epubcfi values are stored in the database.
The converter works by:
1. Resolving the source CFI in the source document (EPUB or KEPUB)
2. Extracting surrounding text at the resolved position
3. Searching for that same text in the target document
4. Building a new CFI pointing to the matched text in the target
This text-content bridging handles the DOM structural differences
between EPUB (text nodes at depth 2) and KEPUB (text nodes wrapped in
<span class="koboSpan"> at depth 3).
Falls back to percentage-based positioning when text search fails,
matching the pattern used by the existing CRE converter.
No changes to existing cfi_converter.go or KOReader conversion code.
Same-package access to unexported functions (resolveCFIToNode, buildCFI,
findTextInNode, etc.) via internal/sync package placement.
Templ requires Go expression interpolation for dynamic attributes,
not string embedding. Change from @click="func('{ id }')" to
@click={ "func('" + id + "')" } for device ID and registration ID
buttons.
Replace the 'Go to Conflicts Page' link with inline conflict resolution.
Each conflict source now has a 'Keep This' button that resolves the
conflict directly from the book detail page.
- Conflict data now keyed by source name (koreader, web) instead of
new/existing, with Source and Timestamp fields
- Display percentage scaled correctly (* 100)
- Fix page field name from current_page to page
- Add conflict resolution JavaScript in book-detail.ts
- Add 10-minute cooldown after resolution to prevent re-detection
- reader.templ: divide DB percentage (0-1) by 100 was wrong; actually
the router multiplies by 100, so template now divides by 100 to get
back to 0-1 range for foliate-js savedPercentage
- reader.ts: add 5-second initTime guard to prevent overwriting
existing progress with initial position on page load
- 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
- Add <title> and <author><name> elements to Atom feed for compatibility
- Remove doubled /opds/opds/ path prefix in feed URLs
- Include ?token= auth param on all OPDS URLs
- Serve kepub links only for Kobo devices
- Fix URL construction for entries and acquisitions
Previously device creation happened in CheckRegistrationStatus (polling
endpoint), which was racy. Now the admin's ApproveDevice handler
creates the device record and stores auth token + device ID on the
registration entry. CheckRegistrationStatus just returns the pre-created
credentials.
Also adds approved/authToken/deviceID/syncEndpoints fields to
PendingRegistration struct.
When resolving a conflict, the last_sync_source is now set to the
winner's actual source name (koreader, web, etc.) rather than always
'manual'. This prevents subsequent saves from re-triggering conflicts.
Also removes the strict oneof validation on the winner field since
the source name is dynamic.
Web reader can now send surrounding text at current reading position.
Stored in reading_progress.context_text for use as CFI resolution
fallback when converting epubcfi to CREngine XPointer.
Forward (push): When KOReader pushes a CREngine XPointer, convert it
to standard epubcfi before storing. Uses CFIConverter.ConvertCREToStandard
with context_text for text search fallback.
Reverse (pull): When KOReader pulls progress, convert stored standard
epubcfi back to CREngine XPointer via CFIConverter.ConvertStandardToCRE.
Returns as koreader_xpointer field in GetMetadata response.
Other changes:
- updateProgressForBook: pass context_text to SaveProgress
- enqueueProgressForBook: pass context_text to queue
- KOReaderProgressData: add KoreaderXPointer field
- New convertCFIToXPointer helper method
- Wire libraryService in main.go for EPUB path resolution
- SaveProgressRequest: add ContextText field
- SaveProgress: carry existing ContextText from DB, overwrite when provided
- ProgressUpdate: add ContextText field for checkpoint sync
- SyncQueueProcessor: serialize/deserialize context_text in sync data
- buildProgressSnapshot: include context_text in snapshot data
Stores surrounding text (~100 chars) at the reader's current position.
Used as fallback for CFI resolution when converting between epubcfi
and CREngine XPointer formats.
Updates:
- schema.sql: add context_text TEXT column, update stored procedure
- queries.sql: add context_text to GetUniversalProgress and
UpdateUniversalProgress queries
- Regenerate sqlc Go code (models.go, querier.go, queries.sql.go)
Implements ConvertCREToStandard which converts CREngine XPointers
(e.g. /body/DocFragment[6]/body/div/p[47]/text().2399) to standard
epubcfi format (e.g. epubcfi(/6/12!/4/2[id]/4/1:7)).
Key components:
- indexChildNodes: faithful port of foliate-js's epubcfi.js algorithm
for computing CFI-compatible child node indices including virtual
positions, null positions between adjacent elements, and text chunks
- preprocessXHTML: converts XHTML self-closing tags (e.g. <a id="x"/>)
to open/close pairs so Go's HTML parser produces the same DOM as the
browser's XHTML parser
- buildCFI: walks up from a text node to body, computing CFI indices
at each level using indexChildNodes
- findTextInNode: regex-based whitespace-flexible text search for
context_text fallback positioning
- convertByPercentageOffset: estimates position via book-wide character
counts when no context_text is available
- ConvertCREToStandard: orchestrates text search → percentage fallback
Supports CREngine XPointer format, CREngine fragment ID format
(#_doc_fragment_N_anchor), and includes round-trip test coverage for
1984 and Crime and Punishment EPUBs.
Previously, when HTMX form submissions (profile update, password change)
returned a successful JSON response like {"message": "profile updated
successfully"}, the raw JSON was swapped into the target div as plain text.
The htmx:afterSwap listener in toast.ts only handled error responses.
Extend it to also intercept successful 2xx JSON responses that contain a
"message" field, showing a green success toast and clearing the raw JSON
from the target element. Only JSON responses are intercepted (checked via
Content-Type header), so legitimate HTML swaps are unaffected.
The timezone feature has been fully implemented across the codebase
(database queries, API handlers, profile form, reader settings).
This planning document is no longer needed.
The cases.Title caser panicked with 'slice bounds out of range' when
processing certain Unicode characters that expand during case transformation
(e.g. ß → SS). This panic crashed the entire server during scanning, causing
WebSocket disconnections and failed scan requests.
On viewports below 970px the header now collapses to a compact bar with
only the Bookhoard logo and a hamburger button. Clicking the hamburger
reveals a slide-down panel containing:
- Full-text search input (wired to the existing debounced search API)
- Navigation links (Library, All Books, Series, Collections, Progress, Devices)
- Collapsible theme switcher with all 7 themes and 4 bookshelf backgrounds
- User section: profile/admin/logout when logged in, inline login form when logged out
Changes:
- templates/header.templ: add hamburger button, mobile panel with all controls,
hide desktop search/theme/user controls below nav breakpoint
- web/src/header.ts: add mobileMenuOpen state to Alpine header component
- web/src/search.ts: refactor initializeSearch to wire both desktop and mobile
search inputs, track active input for results container placement
- tailwind.config.ts: add custom 'nav' screen breakpoint at 970px so the
mobile menu activates before the search bar becomes unusable
- web/static/style.css: rebuilt with new nav breakpoint utility classes
Upgrade from templ v0.3.1001 to v0.3.1020. Generated code changes include
JoinStringErrs -> ResolveAttributeValue and removal of manual EscapeString
calls (now handled internally by ResolveAttributeValue).
Add --mount=type=cache for Go build cache to speed up repeated builds.
Remove the -a flag which forces full rebuilding of all packages and is
unnecessary when using cache mounts.
npm ci requires a package-lock.json and installs exactly what it specifies.
npm install resolves from package.json directly, making the Docker build
fully self-contained — you can clone the repo and run docker build with no
local Node tooling or pre-existing lockfile.
PDFs failed to open with error:
Invalid factory url: "http://localhost:8765/static/undefined"
Root cause: foliate-js's pdfjsPath() uses new URL(dynamicPath, import.meta.url)
to resolve runtime asset paths (standard_fonts/, cmaps/). Vite transforms this
pattern into a static asset map lookup at build time, but can only resolve
known static file paths — not dynamically-constructed directory paths. The
lookup returns undefined, producing a broken URL.
Fix (two parts):
1. foliate-js fork (commit d164d6f): Export an overridable config.pdfjsPath
function. Module-level code (worker, CSS) continues using import.meta.url
directly (works fine with Vite for static filenames). The makePDF function
uses config.pdfjsPath for runtime paths, allowing consumers to override it.
2. Bookhoard changes:
- Update foliate-js dependency to d164d6f
- Override config.pdfjsPath in reader.ts to resolve to /static/vendor/pdfjs/
- Add Vite plugin (pdfjsAssets) that copies standard_fonts/ and cmaps/ from
node_modules to the build output during vite build (the standard approach
used by react-pdf and other pdfjs-dist consumers)
- Remove manual cp commands from build:ts scripts
Replace hardcoded 'podman' with auto-detected CONTAINER_RUNTIME variable
that prefers docker and falls back to podman. Override with:
CONTAINER_RUNTIME=podman make rebuild-app
Remove the ensure_healthy and compose_up macros that worked around
podman-compose hanging on non-systemd systems (e.g., Void Linux with
runt). These are no longer needed — healthchecks are now handled by
plain wait loops in the targets themselves, and compose up -d no longer
blocks on health conditions.
Regenerated database code after sqlc version upgrade from v1.30.0 to
v1.31.1. No functional changes — only the version header in generated
files was updated.
Files: db.go, models.go, querier.go, queries.sql.go
Podman relies on systemd timers to schedule automatic healthchecks. On
non-systemd systems (e.g., Void Linux with runit), healthchecks never
fire, which causes podman-compose to hang forever waiting for
service_healthy conditions that never resolve.
Add two Make macros to handle this transparently:
- compose_up: runs podman compose up -d normally on systemd, but with
a 15-second timeout on non-systemd to create containers without
hanging. Supports passing compose flags via $(call compose_up,args).
- ensure_healthy: on non-systemd systems, waits for the database to
accept connections, manually triggers its healthcheck, starts the app
container, waits for the app health endpoint, and triggers its
healthcheck. On systemd systems, the runtime check is skipped entirely
(zero overhead).
Both macros use a runtime shell check for /run/systemd/system, so the
same Makefile works identically on all systems without parse-time
conditionals.
Applied to all compose-up targets: up, rebuild, rebuild-force,
rebuild-force-db, rebuild-app, rebuild-app-force, test-integration,
and test-env-up.
Refs: https://github.com/containers/podman/pull/27033
- 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.
- storage.ts: Centralize ALL_LIBRARIES = "__all__" sentinel constant.
setSelectedLibrary() now writes both localStorage and a cookie
(selectedLibrary, Path=/, SameSite=Lax, max-age=365d). The
sentinel "__all__" is used in both storage mediums — empty strings
are never stored. getSelectedLibrary() maps __all__ back to "".
Cookie enables server-side rendering to read the stored library
selection without access to localStorage.
- library-switcher.ts: Import ALL_LIBRARIES and setSelectedLibrary/
getSelectedLibrary from storage.ts instead of managing localStorage
directly. Remove local constants.
- dashboard.ts: Remove duplicate localStorage.setItem call that was
overwriting the __all__ sentinel with raw empty string. Fix
reloadPage() and scan-complete handler to work with empty libraryId.
openDashboardSettings/saveDashboardSettings show clear messages for
All Libraries mode.
- collections.ts: Remove library switcher initialization from the
collections list page — the list page no longer has a switcher.
- series.ts: Rewrite to use initLibrarySwitcher from library-switcher
module and switchWithTransition for navigation. Series card links
no longer include library_id in their URLs.
- bookshelf.ts: Autocomplete fetch calls handle empty libraryId
correctly for All Libraries mode.
- search.ts, collection-rules.ts: Use setSelectedLibrary() and
getSelectedLibrary() from storage.ts instead of direct localStorage
access.
Regenerate all templ-generated Go files. These changes are caused
by running templ generate with a slightly different CLI version
(v0.3.1001) than the go.mod dependency (v0.3.1020), resulting in
minor formatting/import diffs across all templates. No functional
changes.
- bookshelf.templ: Fix form field name from "library" to "library_id"
to match the handler's QueryParam("library_id"). Add "All Books"
as the default option in the library filter dropdown. The bookshelf
uses its own inline filter, NOT the universal library switcher.
- collections.templ: Remove @LibrarySwitcher from the collections list
page — collections are not library-specific, so the switcher was
misleading. Fix data-id interpolation bug where {collection.ID} was
rendered as literal text instead of being interpolated.
- series.templ: Replace inline library selector with the universal
@LibrarySwitcher component. SeriesCard links no longer include
library_id since series detail always shows all books.
- dashboard.go: library_id query param is now optional. Empty/missing
library_id is passed as pgtype.UUID{Valid: false} to the service
layer, enabling All Libraries mode.
- series.go: library_id is optional for series listing. GetSeriesBooks
no longer receives a libraryID — it always returns all books in a
series regardless of library.
- collections.go: Restructure GetCollection to handle system
collections (query_type != "") with an optional libraryID. When
libraryID is empty (All Libraries), GetDashboardSections receives
pgtype.UUID{Valid: false} so no library filter is applied.
- dashboard_service.go: Change libraryID parameter from uuid.UUID to
pgtype.UUID across GetDashboardSections, GetDashboardPreferences,
and all helper methods. pgtype.UUID{Valid: false} now signals
"no library filter" (All Libraries), which gets passed through
to sqlc.narg() in the SQL layer.
- series_service.go: Drop libraryID parameter from GetSeriesBooks
entirely. Series are not library-specific — all books in a series
are shown regardless of which library they belong to.
Convert 12 SQL queries to use sqlc.narg('library_id') instead of
direct @library_id parameters. This allows passing a NULL/invalid
pgtype.UUID to mean "no library filter" (i.e., All Libraries),
making the SQL layer correctly handle the optional filter via:
(sqlc.narg('library_id')::uuid IS NULL
OR mi.library_id = sqlc.narg('library_id')::uuid)
Also remove the library_id filter from GetSeriesBooks entirely —
a series is a series regardless of library.
Queries affected:
- GetDashboardSections, GetRecentlyAdded, GetInProgress
- GetHighestRated, GetMostRead, GetAbandonedBooks
- GetLeastRead, GetBooksByTag, GetCollectionItemsForDashboard
- SearchMediaItemsUnified, GetSeriesCardsData
Generated code (queries.sql.go, querier.go) regenerated via sqlc.
Wire up the shared library switcher module on dashboard, collections
list, collection detail, and collection rules pages. All pages use SSR
for initial load and AJAX with fade transitions on library switch.
web/src/collections.ts:
- Add initCollectionsPage() that auto-detects list vs detail page
by checking for #collection-data element
- Collections list: onSwitch fetches /api/collections?library_id=X and
re-renders the grid with per-library book counts
- Collection detail: onSwitch fetches /api/collections/:id?library_id=X
and re-renders the books grid
- Add renderCollectionsGrid() and renderCollectionBooks() with
Alpine.initTree() calls for dynamic content
- Collection cards now link with ?library_id= from selected library
- Update hidden #collection-data data-library-id on switch
web/src/dashboard.ts:
- Replace standalone switchLibrary() with initLibrarySwitcher() +
switchWithTransition() from shared module
- Extract fetchAndRenderSections() helper shared by onSwitch callback,
reloadPage(), and saveDashboardSettings()
- Remove inline #library-select change listener and switch-library
data-action handler (now handled by shared module)
- Scan-complete event handler unchanged (independent incremental logic)
web/src/collection-rules.ts:
- Update backToCollection() to preserve library context by appending
?library_id= from localStorage selectedLibrary key
Replace inline library switcher HTML with shared LibrarySwitcher component
across all collection pages and the dashboard.
templates/collections.templ (Collection):
- Update signature to accept libData []LibraryData, currentLibraryID
- Add @LibrarySwitcher(libData, currentLibraryID) after header
- Change x-init to initCollectionsPage() for unified initialization
templates/collections.templ (CollectionDetail):
- Update signature to accept libData []LibraryData
- Add @LibrarySwitcher(libData, libraryID) after header
- Fix broken "Back to Collections" button: replace non-existent
backToCollections Alpine method with a plain <a href="/collections"> link
- Change x-init to initCollectionsPage() for unified initialization
templates/dashboard.templ:
- Replace 46-line inline sticky library selector (lines 20-66) with
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
- Dashboard-specific settings and refresh buttons extracted into the
DashboardActions sub-component via the variadic actions parameter
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
Add optional library_id query parameter support to GetCollections and
GetCollection API handlers for library-scoped book filtering.
GetCollections (GET /api/collections?library_id=X):
- When library_id is provided, include per-library book_count in the
response by querying GetCollectionItemsForDashboard for each collection
- When omitted, returns all collections as before (backward compatible)
- Added BookCount field to CollectionResponse struct
GetCollection (GET /api/collections/:id?library_id=X):
- System collections (non-empty QueryType): uses DashboardService to
fetch library-scoped sections, matching the existing SSR handler logic
- User collections: uses GetCollectionItemsForDashboard for
library-filtered results, excluding soft-deleted items
- When library_id is omitted, returns all books as before
Add reusable library switcher infrastructure that can be used across
dashboard, collections list, and collection detail pages.
New files:
- templates/library_switcher.templ: Shared LibrarySwitcher component
with variadic actions slot for page-specific buttons (e.g. dashboard
settings/refresh). Includes DashboardActions sub-component.
- web/src/library-switcher.ts: Shared module providing:
- initLibrarySwitcher(): syncs dropdown with localStorage, attaches
change listener with configurable onSwitch callback
- switchWithTransition(): generic fade-out -> spinner -> fetch ->
fade-in transition used by all pages
- getCurrentLibraryId(): reads "selectedLibrary" from localStorage
Modified:
- web/src/main.ts: import new library-switcher module
- web/src/types/api.d.ts: add book_count field to CollectionData
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
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.
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
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
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
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
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
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
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
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
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.
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).
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.)
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
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.
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.
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.
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
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
- 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)
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.
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.
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)
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
- 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
Add textToString, tagSliceToString, stringSliceToString, and
formatDateForInput to convert pgtype/[]string values into HTML input
value attributes for the metadata editor form fields.
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