Introduces a centralized ProgressService that handles all reading progress
writes across web, KOReader, and Kobo clients. The service implements:
- Merge strategy: reads existing progress first, then only overwrites
non-nil fields from the incoming request. This fixes the data loss bug
where partial updates (e.g., Kobo last-read-place sending only epubcfi
and chapter) would NULL out percentage, character_offset, etc.
- Enrichment: computes missing fields from available data:
- character_offset from percentage + total_characters
- current_page from percentage + total_pages
- percentage from current_page + total_pages (reverse)
- percentage from character_offset + total_characters (reverse)
- Conflict detection: when a different source writes progress within 5
minutes with >1% difference, records a sync_conflicts row and broadcasts
a WebSocket notification for real-time UI alerts.
- Broadcast control: SaveProgressRequest.Broadcast flag lets Kobo
last-read-place and SyncFromServer skip WebSocket broadcasts.
- Pointer fields on SaveProgressRequest: nil means preserve existing,
non-nil means overwrite. Eliminates ambiguity between zero values
and not-provided fields.
Also adds unit tests for buildProgressSnapshot helper function.
All 25 templ-generated Go files had their error-handling FileName fields
updated from bare filenames (e.g. `dashboard.templ`) to path-prefixed
filenames (e.g. `templates/dashboard.templ`). This reflects a change in
how the templ compiler resolves source file paths, likely due to running
generation from the project root instead of within the templates directory.
The change is purely cosmetic and only affects runtime error messages,
not application behavior.
Affected templates:
- Admin: library, processing_issues, settings, sidebar, users
- Reader/Book: book_detail, book_detail_modals, bookshelf
- Collections: collection_modal, collection_rules, collections
- Other pages: conflicts, custom_section, dashboard, devices,
docs, error, filter_item, header, profile_form, profile_modal,
progress, queue, unlinked_books
- API: api_explorer
Connect the existing progress_mode setting dropdown to the reader's
progress display. Four modes are now functional:
- pages: overall percent + page/location number (default, existing)
- chapter: chapter title + page X / Y within current section
- percentage: overall percent only
- time-left: percent + estimated time remaining via reading speed API
The progress display in the bottom bar is now clickable to cycle through
modes with immediate visual feedback. The settings dropdown is bound
with x-model for persistence. Reading speed is fetched once on init
from the backend reading-speed API for time-left estimates.
Regenerated all _templ.go files after running templ generate. Changes
include updated FileName references (relative path normalization) and
line number adjustments from the templ code generator.
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
Add fields to ReaderMetadata and ReadingProgress template types to support
KOReader-like progress display:
ReaderMetadata:
- TotalCharacters: from media item, used for estimated page calculation
- EstimatedPages: computed via sync.EstimatedPages()
ReadingProgress:
- Chapter: current chapter index from reading_progress
- ChapterProgress: within-chapter progress (0-100, multiplied from DB fraction)
- FormatGroup: item format for conditional display logic
These fields enable format-aware progress display (pages for comics/PDFs,
estimated pages for reflowable, percentage for all).
Reflowable ebooks (EPUBs) don't have inherent page numbers since layout
depends on device settings. Add an EstimatedPages() function that converts
total character count to an estimated print page count using the industry
standard of 1800 characters per page.
This provides a consistent, device-independent page count for progress
display (e.g., "Page 89 of 196" for a reflowable EPUB), matching how
KOReader and similar readers handle the same problem.
The DetectChapters function in reader.go was serializing chapter detection
results to JSON but then discarding the bytes with `_ = metadataBytes`
instead of writing them to the database. This meant chapter_metadata in
media_items was never populated, forcing re-detection on every request.
Replace the no-op discard with an actual UpdateMediaItemChapterMetadata()
call using the existing sqlc-generated query.
The media scanner never populated page_count or total_characters in
media_items, leaving progress display and reading position calculations
with no reliable data. This commit fixes data population for all formats:
Comics (CBZ/CBR/CB7/CBT):
- Add countArchiveImages() helper that walks archive entries and counts
image files (.jpg, .jpeg, .png, .gif, .webp)
- Call it during comic metadata merge to set metadata.PageCount
PDFs:
- Extract pdfInfo.PageCount from the pdfcpu library (already available
from PDFInfo call, just never used) and set metadata.PageCount
Reflowable EPUBs:
- Use book.AllChaptersText() to compute metadata.TotalCharacters
- Use book.ChapterCount() to set metadata.ChapterCount
Fixed-layout EPUBs (manga/comics in EPUB format):
- Merge .epub into the .cbz case in countArchiveImages since both are
ZIP archives with images
- Detect fixed-layout EPUBs via DetectFixedLayoutEPUB() in both the
Calibre sidecar path (mergeMetadata) and the no-sidecar path
(extractMetadata), counting images when fixed-layout is detected
Format group on creation:
- Remove the guard condition on UpdateMediaItemFormatGroup so that
format_group, is_reflowable, and has_fixed_layout are set immediately
for every new item (not just items with text data)
- Use DetectFixedLayoutEPUB() instead of hardcoding all .epub as
reflowable, correctly classifying fixed-layout EPUBs
Also pass PageCount to CreateMediaItem and add PageCount,
TotalCharacters, and ChapterCount fields to the MediaMetadata struct.
The web reader had all the infrastructure for progress persistence
(updateReadingProgress/getReadingProgress API functions, PUT/GET
endpoints, database queries) but the reader.ts never called them.
Changes:
- Add debounced (2s) saveProgress call on every relocate event that
PUTs percentage, current_page, total_pages, and epubcfi to the
existing /api/media-items/:id/progress endpoint
- Replace renderer.next() with view.init({ lastLocation }) to restore
the reader to the last saved position on load (CFI first, then
fraction fallback, then default first page)
- Pass savedPercentage and savedCfi from server-side progress data
through readerInitExpr config to the JS initReader function
- Add mediaItemId and saveTimeout to the Alpine data object
This fixes both the blank /progress page and the missing progress
section on book detail pages — both were empty because the
reading_progress table never received any data from the web reader.
Go's new() builtin takes a type and allocates a zero value — it cannot
wrap an expression. All instances of new(someExpression) were compile
errors. Replace each with a local variable assignment and address-of
operator.
Affected files:
- handlers/koreader.go: progress field pointers (Chapter, Page, etc.)
- handlers/kobo.go: pagesRemaining pointer
- handlers/queue.go: uuidPtrToString and timestamptzPtrToString helpers
- router/reader.go: bookmark pageNumber and chapterNumber pointers
- services/media_scanner.go: validation error message pointers
- services/worker.go: StartedAt and CompletedAt timestamps
- sync/offline.go: GetDeviceStatus return pointer
- tests/device_test.go: SyncEnabled and SyncFrequencyMinutes pointers
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.
When switching libraries via TypeScript, renderBookCard() built book
cards as <div> elements with data-action="view-book" for event
delegation, but the click handler was commented out — making books
unclickable after any library switch. The SSR path used proper <a> tags.
Now renderBookCard() wraps cards in <a href="/media/{id}"> to match the
SSR BookCard template, so book links work identically regardless of
whether content was server-rendered or client-rendered.
Also removed the dead view-book handler code and viewBook() stub.
Additionally fixed a listener re-registration bug where the input and
library-select change listeners were nested inside the click callback,
causing them to be registered N times after N clicks. Moved them to
initDashboard() scope so they register exactly once.
The back link in ReaderChrome used literal curly braces inside the href
attribute string (href="/media-items/{ metadata.MediaItemID }") which
doesn't interpolate the variable in templ. Changed to use the correct
templ expression syntax: href={ "/media/" + metadata.MediaItemID }.
Also fixed minor formatting issues:
- Normalize whitespace in comment after closing div
- Collapse empty navigator-viewport div to single line
Remove hardcoded values for database/environment IDs (media_item_id,
library IDs, collection_id, user_id, etc.) and mark them as secret so
that changing them per machine won't keep syncing to git.
Cover image URLs with special characters like parentheses, #, ?, or
spaces would break because browsers interpret them as URL delimiters.
Apply url.PathEscape() per path segment in ResolveMediaURL so the
server can correctly resolve files like "Wonder Woman (2016) #001.cbz.cover.jpg".
Also adds a package doc comment and fixes the exported function comment.
Comic archive formats (.cbz, .cbr, .cb7, .cbt) and .kepub files were
falling through to the default case in extractMetadata(), which only
set the title from the filename. This meant ComicInfo.xml was never
parsed and no cover images were extracted for comics without a Calibre
metadata.opf sidecar file.
The fix adds dedicated switch cases:
- .cbz/.cbr/.cb7/.cbt: calls mergeMetadata() with nil, which triggers
existing ComicInfo.xml parsing (title, series, issue number, writer,
publisher, genre, reading direction, etc.) and cover image extraction
from the archive. Falls back to sidecar cover if no image is found.
- .kepub: treated the same as .epub since KEPUB is an EPUB variant,
enabling full metadata and cover extraction.
TestCollectionSearchLibraryFilter's 'invalid library_id' case was
expecting a 200 with empty results, but the search handler correctly
returns 404 when no results are found. The test also consumed the
response body for debug logging then tried to JSON-decode the same
body (causing EOF). Add expectedStatus field to the test struct and
return early when a specific non-200 status is expected.
Three issues fixed in processing_issues_test.go:
- Empty UUID: handler returns 400 (uuid.Parse rejects empty string), not 404.
Fix expectedStatus in both List and Stats validation tests.
- Path traversal: raw '../../' in URL creates extra path segments that don't
match the route. Use url.PathEscape so the string is treated as a single
path parameter, letting the handler reject it with 400.
- SQL injection: raw special characters (semicolons, quotes) caused
httptest.NewRequest to panic. url.PathEscape prevents the panic and the
handler rejects the decoded value via uuid.Parse.
- Remove unsupported 'audiobooks' library type from
TestProcessingIssuesDifferentLibraryTypes (only ebooks/comics/manga exist
in the database schema).
The setupTestServer() helper in test_helpers_test.go was not creating
a ProcessingIssuesHandler and not passing one to the router config,
causing a nil pointer dereference when any processing issues route was
hit during tests. Add handler creation and wire it into routerConfig
to match how cmd/server/main.go does it.
Both ListProcessingIssues and GetProcessingIssueStats were reading the
URL parameter 'libraryId', but the routes in internal/router/library.go
define the param as ':id'. This caused both endpoints to always fail with
an invalid library ID error since c.Param('libraryId') returns an empty
string that can't be parsed as a UUID.
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
DismissAllResolved was calling ListSyncConflictsByUser which filters to
'unresolved' conflicts only, so it could never find the user_resolved or
bulk_resolved conflicts it was trying to delete. The query always returned
an empty set, making dismiss-all a no-op.
Fix the leading space in three SQL query name annotations (ListConflictsByUser,
ListAllConflictsByUserAndStatus, CheckForProgressConflicts) that prevented
sqlc from generating their Go functions. Regenerate the query code and swap
DismissAllResolved to use ListConflictsByUser (no status filter) — the
existing Go loop already filters by resolution_status.
The builder stage was previously bumped to Go 1.26 but the test-runner stage
remained on Go 1.25, causing 'go.mod requires go >= 1.26.0' errors during
test execution.
Go 1.26 introduced stricter validation of replace directives in dependency
go.mod files, which broke 'go install sqlc@latest' (sqlc v1.31.0 has replace
directives). Replace go install with a direct binary download from GitHub
releases, matching the existing kepubify download pattern.
Bump test-runner from golang:1.25-alpine to golang:1.26-alpine to match the
go.mod requirement.
Replace all unhandled resp.Body.Close() calls throughout the test suite:
- Deferred calls: replace 'defer VAR.Body.Close()' with a closure that explicitly
discards the error via 'defer func(Body io.ReadCloser) { _ = Body.Close() }(VAR.Body)'
- Immediate calls: replace 'VAR.Body.Close()' with '_ = VAR.Body.Close()'
Replace all unhandled json.NewDecoder(VAR.Body).Decode(&x) calls with error capture
and require.NoError assertion. Files using httptest.ResponseRecorder (collections_preview,
processing_issues) use 'err :=' declaration; suite-style tests (scanner_integration,
dashboard_integration) use s.T() instead of t.
Replace direct error equality check with errors.Is() in media_scanner_hash_test.
In analytics_test.go, improve defer patterns by capturing resp.Body as a named parameter
to avoid stale references, and add require.NoError() checks on all json.NewDecoder().Decode()
calls that were previously silently ignoring decode errors.
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)
Add the 'reading_mode' field with 'dark' | 'light' values to the
ReaderSettings TypeScript interface, preparing the frontend for a
dark/light reading mode toggle.
Two changes to EPUB metadata extraction:
1. Restructure extractMetadata so that fixed-layout detection and cover
extraction always run for EPUBs, even when extractEPUBMetadata returns
an error. Previously, a partial failure from the EPUB parser would skip
cover and format detection entirely, leaving books without covers.
2. Remove the language restriction (ja/jpn) from manga reading direction
detection. Manga tagged with 'manga' should default to RTL regardless
of the language metadata, since the tag is an explicit signal from the
user or metadata source.
In applyProgressResolution and applyResolution, currentPage and totalPages
were unconditionally read from existingProgress even when the preceding
query returned err (no rows). This caused a nil pointer dereference when
no existing reading progress existed for a media item. Now declare the
variables as zero-value pgtype.Int4 and only populate them from
existingProgress when err is nil.
Replace direct equality checks (err != pgx.ErrNoRows) with the idiomatic
errors.Is(err, pgx.ErrNoRows) pattern. This is the recommended Go practice
for error comparison as it correctly handles wrapped errors from error
chains, making the code more robust against future refactoring that might
wrap errors with fmt.Errorf and %w.
When opening a sevenzip archive, the init() method calls sr :=SevenZipReader()
but never checked if sr was nil before using it. This could cause a nil
pointer dereference when processing malformed or empty archives. Add an
explicit nil check returning errFormat early if the subreader is nil.
Also fixes a minor import grouping whitespace issue.
Replace the previous mock/httptest-based conflict tests with
integration tests that exercise the full HTTP stack against a live
test server with a real database. Changes include:
- Add shared test helpers (setupConflictTest, createTestConflict,
makeConflictData) to reduce boilerplate across test files
- Split monolithic TestConflictDetection and TestConflictsBulkOperations
into focused test functions per scenario
- Test conflict detection, bulk resolution (most_recent, highest_progress,
manual strategies), and edge cases (empty IDs, invalid UUIDs,
unauthorized access)
- Verify actual database state after resolution, not just HTTP response
Add table-driven tests for the conflict handler's source selection
methods: GetMostRecentSource, GetHighestProgressSource, and
GetEarliestSource. Covers cases where each device wins, ties, and
missing/invalid data.
strings.Title has been deprecated since Go 1.18 because it does not
handle Unicode properly. Replace it with cases.Title from
golang.org/x/text which correctly handles language-specific title
casing. Applied to breadcrumb generation and document title formatting.
Add a structured plan for translating Fish shell config.fish into
equivalent Zsh .zshrc syntax, preserving the existing Forge-managed
block in the target file.
Simplify pointer creation in media scanner validation messages and worker
job timestamps by using inline new() instead of local variable + address-of.
In media_scanner.go this cleans up three validation error message returns
(manga/comics library format checks). In worker.go it simplifies StartedAt
and CompletedAt timestamp assignments.
Simplify pointer creation across kobo, koreader, and queue handlers by
replacing the two-step pattern (assign to local, then take address) with
inline new() calls. This reduces verbosity without changing behavior:
Before:
remaining := int(a - b)
pagesRemaining = &remaining
After:
pagesRemaining = new(int(a - b))
Covers page calculations, chapter/progress fields, UUID formatting,
and timestamp string conversions.
Replace the original 5-theme allowlist (light, sepia, dark, night,
high-contrast) with a richer 20-theme palette organized into tonal
families: neutrals (light, paper, slate, oled), warm tones (sepia,
parchment, warm, candlelight), cool tones (azure, sky, arctic, frost),
and evening tones (dusk, sunset, twilight, forest, moss, solarized).
The backend validator in UpdateSettings now accepts all 20 theme names,
and the frontend Tailwind build is updated to include the new theme CSS
variables and preflight reset.
- Remove Playwrite NZ Guides test font and all references (FONT_MAP,
FONT_FILES, reader-fonts.css, dropdown option, font files)
- Move settings panel from left sidebar to right sidebar (left sidebar
now only contains TOC)
- Move Restore Defaults button from top bar icon to a styled button
inside the settings panel, side by side with Done button
The paginator renders inside a sandboxed iframe that blocks @font-face
URL fetches. Fonts were never loading — all font-family rules fell back
to the generic 'serif' system font, making every reading font identical.
Fix: fetch font files on the parent page, create blob: URLs via
URL.createObjectURL(), and use those blob URLs in the @font-face rules
injected into the iframe via setStyles(). Blob URLs are always
same-origin with the creating document, so the sandboxed iframe can
access them with allow-same-origin.
Also added Playwrite NZ Guides as a test font for verifying font
switching works.
Fonts weren't loading because the paginator renders inside a sandboxed
iframe. @font-face declarations in the parent page's CSS are invisible
to the iframe's document. Even injecting @font-face rules via
setStyles() may not trigger font loading in sandboxed iframes.
Fix: inject a <link> to reader-fonts.css directly into the iframe's
document on each section load, so @font-face declarations are parsed
in the iframe's own document context where font-family rules can
reference them.
Also:
- Remove foliate-themes.css entirely (no longer needed)
- Set viewport background color directly via JS using THEME_COLORS map
- Remove reading theme CSS classes from viewport element
background: none on html/body exposed the iframe's default black
background in gaps around the content. Now uses background-color
matching the theme color, and stops forcing background on body *
which caused black bars around page margins.