From 82d0d378a6fc54cfb6898a70f913bfdd237ce284 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 25 Apr 2026 21:16:52 -0400 Subject: [PATCH] feat(reader): send richer progress payload with chapter boundaries and zoom The reader's saveProgress() now sends a more complete payload to the backend so ProgressService has more data for enrichment and merge: - chapter: computed from TOC boundary index instead of missing - reading_mode: current display mode (page, chapter, percent, time-left) - zoom_level: for fixed-layout books (renderer.zoomPercent / 100) - current_page: real page number for fixed-layout, location.current for reflowable - total_pages: section count for fixed-layout, location.total for reflowable Adds computeChapterPageBoundaries(doc) for reflowable EPUBs that maps TOC anchors to rendered page numbers, recomputes after fonts load. Adds computeFixedLayoutChapterBoundaries() for fixed-layout books that resolves TOC hrefs to page indices via view.resolveNavigation(). Updates reader.templ to expose isFixedLayout to Alpine init. --- templates/reader.templ | 2 +- templates/reader_templ.go | 2 +- web/src/reader/reader.ts | 262 +++++++++++++++++++++++++++++++++----- 3 files changed, 235 insertions(+), 31 deletions(-) diff --git a/templates/reader.templ b/templates/reader.templ index 3248a16..91b7585 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -154,7 +154,7 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
-
+
if progress.FormatGroup == "reflowable" { if metadata.EstimatedPages > 0 { { fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) } diff --git a/templates/reader_templ.go b/templates/reader_templ.go index 81bafcc..97ac934 100644 --- a/templates/reader_templ.go +++ b/templates/reader_templ.go @@ -181,7 +181,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index aaff163..98680f4 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -395,6 +395,11 @@ document.addEventListener("alpine:init", () => { tocItem: FoliateTocItem | null; section: { current: number; total: number }; } | null, + chapterBoundaries: [] as { + id: number; + label: string; + startPage: number; + }[], async initReader(config: { mediaItemId: string; fileUrl: string; @@ -440,11 +445,12 @@ document.addEventListener("alpine:init", () => { this.renderer.addEventListener("zoom", () => { this.zoomPercent = this.renderer.zoomPercent; }); + this.computeFixedLayoutChapterBoundaries(); } else { this.renderer.setStyles?.(this.buildCSS()); } this.view.addEventListener("load", (e: any) => { - const { doc } = e.detail; + const { doc, index } = e.detail; const link = doc.createElement("link"); link.rel = "stylesheet"; link.href = "/static/reader-fonts.css"; @@ -452,6 +458,10 @@ document.addEventListener("alpine:init", () => { doc.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); + if (!this.isFixedLayout) { + this.computeChapterPageBoundaries(doc); + doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc)); + } }); this.view.addEventListener("relocate", (e: any) => { const { fraction, location, pageItem, cfi, tocItem, section } = @@ -521,6 +531,36 @@ document.addEventListener("alpine:init", () => { async saveProgress(fraction: number, location: any, cfi: string) { const token = getToken(); if (!token || !this.mediaItemId) return; + + const body: Record = { + percentage: fraction, + current_page: this.isFixedLayout + ? (this.renderer?.index ?? 0) + 1 + : location?.current ?? 0, + total_pages: this.isFixedLayout + ? this.book?.sections?.filter((s: any) => s.linear !== "no") + ?.length ?? 0 + : location?.total ?? 0, + epubcfi: cfi || "", + reading_mode: this.readingMode || undefined, + }; + + const tocItem = this.lastRelocateDetail?.tocItem; + if (tocItem) { + if (tocItem.label) { + const boundaryIdx = this.chapterBoundaries.findIndex( + (b: any) => b.tocItem === tocItem, + ); + if (boundaryIdx !== -1) { + body.chapter = boundaryIdx; + } + } + } + + if (this.isFixedLayout && this.renderer?.zoomPercent) { + body.zoom_level = this.renderer.zoomPercent / 100; + } + try { await fetch(`/api/media-items/${this.mediaItemId}/progress`, { method: "PUT", @@ -528,12 +568,7 @@ document.addEventListener("alpine:init", () => { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - percentage: fraction, - current_page: location?.current ?? 0, - total_pages: location?.total ?? 0, - epubcfi: cfi || "", - }), + body: JSON.stringify(body), }); } catch (_e) { // silent fail — progress save is non-critical @@ -723,23 +758,87 @@ document.addEventListener("alpine:init", () => { case "percentage": return percent; case "chapter": { - if (!section || !this.sectionFractionsArr.length) return percent; - const idx = section.current; - const startFrac = this.sectionFractionsArr[idx] ?? 0; - const endFrac = this.sectionFractionsArr[idx + 1] ?? 1; - const sectionFrac = endFrac - startFrac; - if (sectionFrac <= 0) return percent; - const totalInSec = Math.max( - 1, - Math.round(sectionFrac * location.total), - ); - const currentInSec = Math.max( - 1, - Math.round((fraction - startFrac) * location.total), - ); - const clamped = Math.min(currentInSec, totalInSec); - const label = tocItem?.label ? `${tocItem.label} · ` : ""; - return `${label}${clamped} / ${totalInSec}`; + if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) { + const totalSections = this.book?.sections?.filter( + (s: any) => s.linear !== "no", + )?.length ?? 0; + const currentPage = this.renderer?.index ?? 0; + const boundaryIdx = this.chapterBoundaries.findIndex( + (b) => b.id === tocItem.id, + ); + if (boundaryIdx !== -1 && totalSections > 0) { + const chapterStart = + this.chapterBoundaries[boundaryIdx].startPage; + const nextBoundary = + this.chapterBoundaries[boundaryIdx + 1]; + const chapterEnd = nextBoundary + ? nextBoundary.startPage + : totalSections; + const chapterPages = Math.max(1, chapterEnd - chapterStart); + const currentInChapter = Math.max( + 1, + Math.min(currentPage - chapterStart + 1, chapterPages), + ); + const label = tocItem.label + ? `${tocItem.label} · ` + : ""; + return `${label}${currentInChapter} / ${chapterPages}`; + } + } + if (tocItem && this.chapterBoundaries.length > 0) { + const boundaryIdx = this.chapterBoundaries.findIndex( + (b) => b.id === tocItem.id, + ); + if (boundaryIdx !== -1) { + const currentPage = this.renderer?.page ?? 1; + const chapterStart = + this.chapterBoundaries[boundaryIdx].startPage; + const nextBoundary = + this.chapterBoundaries[boundaryIdx + 1]; + const totalPages = this.renderer?.pages ?? 0; + const chapterEnd = nextBoundary + ? nextBoundary.startPage + : totalPages > 2 + ? totalPages - 1 + : chapterStart + 1; + const chapterPages = Math.max(1, chapterEnd - chapterStart); + const currentInChapter = Math.max( + 1, + Math.min(currentPage - chapterStart + 1, chapterPages), + ); + const label = tocItem.label + ? `${tocItem.label} · ` + : ""; + return `${label}${currentInChapter} / ${chapterPages}`; + } + } + if (section && this.sectionFractionsArr.length > 1) { + const pageInfo = this.getRenderedPageInfo(); + const pageBase = pageInfo?.total ?? location.total; + const idx = section.current; + const startFrac = this.sectionFractionsArr[idx] ?? 0; + const endFrac = this.sectionFractionsArr[idx + 1] ?? 1; + const sectionFrac = endFrac - startFrac; + if (sectionFrac > 0 && pageBase > 0) { + const totalInSec = Math.max( + 1, + Math.round(sectionFrac * pageBase), + ); + const currentInSec = Math.max( + 1, + Math.round((fraction - startFrac) * pageBase), + ); + const clamped = Math.min(currentInSec, totalInSec); + const label = tocItem?.label + ? `${tocItem.label} · ` + : ""; + return `${label}${clamped} / ${totalInSec}`; + } + } + if (location.total > 0) { + return `${percent} · ${location.current} / ${location.total}`; + } + return percent; } case "time-left": { if (this.readingSpeedPpm > 0 && location.total > 0) { @@ -752,13 +851,23 @@ document.addEventListener("alpine:init", () => { } return `${percent} · ~${mins} min left`; } - return percent; + return `${percent} · ~-- min left`; } default: { - const loc = pageItem - ? `Page ${pageItem.label}` - : `Loc ${location.current}`; - return `${percent} · ${loc}`; + if (this.isFixedLayout) { + const pageInfo = this.getRenderedPageInfo(); + if (pageInfo) { + return `${pageInfo.current} / ${pageInfo.total}`; + } + } + if (pageItem) { + return `${percent} · Page ${pageItem.label}`; + } + const pageInfoReflow = this.getRenderedPageInfo(); + if (pageInfoReflow) { + return `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`; + } + return `${percent} · ${location.current} / ${location.total}`; } } }, @@ -802,6 +911,101 @@ document.addEventListener("alpine:init", () => { }, applyProgressMode() { saveSettings({ progress_mode: this.progressMode as any }); + if (this.lastRelocateDetail) { + const { fraction, location, pageItem, tocItem, section } = + this.lastRelocateDetail; + this.progressText = this.formatProgress( + fraction, + location, + pageItem, + tocItem, + section, + ); + const slider = document.getElementById( + "progress-slider", + ) as HTMLInputElement; + if (slider) { + slider.title = this.progressText; + } + } + }, + computeFixedLayoutChapterBoundaries() { + if (!this.book?.toc || !this.view) return; + const flat = this.flattenTOC(this.book.toc); + for (const item of flat) { + try { + const resolved = this.view.resolveNavigation(item.href); + if (resolved?.index != null) { + this.chapterBoundaries.push({ + id: item.id, + label: item.label, + startPage: resolved.index, + }); + } + } catch { + // skip unresolvable anchors + } + } + this.chapterBoundaries.sort( + (a, b) => a.startPage - b.startPage, + ); + }, + computeChapterPageBoundaries(doc: Document) { + this.chapterBoundaries = []; + if (!this.book?.toc) return; + const size = this.renderer?.size; + if (!size) return; + const flat = this.flattenTOC(this.book.toc); + for (const item of flat) { + const fragment = item.href?.split("#")[1]; + if (!fragment) continue; + try { + const el = this.book.getTOCFragment?.(doc, fragment); + if (!el) continue; + const rect = el.getBoundingClientRect(); + const page = Math.floor(rect.left / size) + 1; + this.chapterBoundaries.push({ + id: item.id, + label: item.label, + startPage: page, + }); + } catch { + // skip unresolvable anchors + } + } + this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage); + }, + getRenderedPageInfo(): { total: number; current: number } | null { + if (this.isFixedLayout) { + const total = + this.book?.sections?.filter( + (s: any) => s.linear !== "no", + )?.length ?? 0; + if (total === 0) return null; + const index = this.renderer?.index ?? 0; + return { total, current: index + 1 }; + } + const pages = this.renderer?.pages; + if (pages != null && pages > 2) { + return { total: pages - 2, current: (this.renderer.page ?? 1) - 1 }; + } + return null; + }, + progressTooltip(): string { + const labels: Record = { + pages: "Pages", + chapter: "Chapter", + percentage: "Percentage", + "time-left": "Time Left", + }; + const label = labels[this.progressMode] ?? "Pages"; + if ( + this.progressMode === "chapter" && + this.chapterBoundaries.length === 0 + ) { + return `${label} · no chapter data · Click to switch`; + } + return `${label} · Click to switch`; }, handleKeydown(event: KeyboardEvent) { const k = event.key;