From 423a1c0bf7d503715bef886cbc0440529e4baa1f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 7 Jun 2026 00:03:46 -0400 Subject: [PATCH] feat(sync): use page index as canonical locator for fixed-layout & comic formats 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. --- internal/handlers/kobo.go | 16 ++++++++++++++ internal/sync/progress.go | 45 ++++++++++++++++++++++++++------------- templates/reader.templ | 8 +++++++ templates/reader_templ.go | 28 +++++++++++++++--------- web/src/reader/reader.ts | 14 ++++++++---- 5 files changed, 82 insertions(+), 29 deletions(-) diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 6c9479a..8e0e10c 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -408,6 +408,20 @@ func (h *KoboHandler) Markup(c *echo.Context) error { pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} percentage := readingSync.PercentRead / 100.0 + // Kobo only sends a percentage. For fixed-layout & comic formats the page + // index is the canonical locator, so derive it from the known page count. + var currentPage, totalPages *int + if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil { + if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) { + if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 { + total := int(mediaItem.PageCount.Int32) + page := wsync.PercentageToPage(percentage, total) + currentPage = &page + totalPages = &total + } + } + } + if h.progressSvc != nil { _, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{ MediaItemID: pgMediaUUID, @@ -415,6 +429,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error { Source: "kobo", DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, Percentage: &percentage, + CurrentPage: currentPage, + TotalPages: totalPages, DeviceType: "kobo", DeviceName: device.DeviceName, Broadcast: true, diff --git a/internal/sync/progress.go b/internal/sync/progress.go index 4e4c1b7..6ee754f 100644 --- a/internal/sync/progress.go +++ b/internal/sync/progress.go @@ -400,21 +400,36 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true} params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true} - if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 { - charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64) - params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true} - } - if params.Percentage.Valid && !params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 { - page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32)) - params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true} - } - if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.Percentage.Valid { - pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32)) - params.Percentage = pgtype.Float8{Float64: pct, Valid: true} - } - if params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 && !params.Percentage.Valid { - pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64) - params.Percentage = pgtype.Float8{Float64: pct, Valid: true} + formatGroup := FormatGroup(mediaItem.FormatGroup) + isFixed := formatGroup == FormatGroupFixedLayout || formatGroup == FormatGroupComicArchive + if isFixed { + // Fixed-layout & comic formats: the page index is the canonical locator. + // Derive percentage from page; CFI/character-offset are meaningless for + // image-based content, so they are intentionally left untouched here. + if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 { + pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32)) + params.Percentage = pgtype.Float8{Float64: pct, Valid: true} + } else if params.Percentage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.CurrentPage.Valid { + page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32)) + params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true} + } + } else { + if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 { + charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64) + params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true} + } + if params.Percentage.Valid && !params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 { + page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32)) + params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true} + } + if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.Percentage.Valid { + pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32)) + params.Percentage = pgtype.Float8{Float64: pct, Valid: true} + } + if params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 && !params.Percentage.Valid { + pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64) + params.Percentage = pgtype.Float8{Float64: pct, Valid: true} + } } conflictDetected := false diff --git a/templates/reader.templ b/templates/reader.templ index 95b9ed1..b94d04f 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -19,6 +19,14 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string { if progress.EpubCfi != "" { config["savedCfi"] = progress.EpubCfi } + // Fixed-layout & comic formats: the page index is the canonical, exact + // locator (pages are fixed images). Pass it so the reader restores by page. + if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 { + config["savedPage"] = progress.CurrentPage + if progress.TotalPages > 0 { + config["savedTotalPages"] = progress.TotalPages + } + } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) } diff --git a/templates/reader_templ.go b/templates/reader_templ.go index 46d91e8..f6d208a 100644 --- a/templates/reader_templ.go +++ b/templates/reader_templ.go @@ -27,6 +27,14 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string { if progress.EpubCfi != "" { config["savedCfi"] = progress.EpubCfi } + // Fixed-layout & comic formats: the page index is the canonical, exact + // locator (pages are fixed images). Pass it so the reader restores by page. + if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 { + config["savedPage"] = progress.CurrentPage + if progress.TotalPages > 0 { + config["savedTotalPages"] = progress.TotalPages + } + } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) } @@ -59,7 +67,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 32, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 40, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -72,7 +80,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata, progress)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 41, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 49, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -162,7 +170,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var5 templ.SafeURL templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 79, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 87, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -175,7 +183,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 82, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 90, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -190,7 +198,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% ยท Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 160, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 168, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -200,7 +208,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 162, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 170, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -211,7 +219,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 165, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 173, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -351,7 +359,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(bookmark.CfiPosition) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 382, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 390, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -364,7 +372,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 385, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 393, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -377,7 +385,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Position) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 387, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 395, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 48f29c3..c3b51c2 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -412,6 +412,8 @@ document.addEventListener("alpine:init", () => { mangaType: string; savedPercentage?: number; savedCfi?: string; + savedPage?: number; + savedTotalPages?: number; }) { this.mediaItemId = config.mediaItemId; this.settings = await loadSettings(); @@ -517,14 +519,18 @@ document.addEventListener("alpine:init", () => { document.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); - if (config.savedCfi) { - await this.view.init({ lastLocation: config.savedCfi }); + if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) { + // Fixed-layout & comics: a page index is the exact, universal locator. + // A bare number navigates directly to the section index in foliate. + await this.view.init({ lastLocation: config.savedPage - 1 }) + } else if (config.savedCfi) { + await this.view.init({ lastLocation: config.savedCfi }) } else if (config.savedPercentage && config.savedPercentage > 0) { await this.view.init({ lastLocation: { fraction: config.savedPercentage }, - }); + }) } else { - await this.view.init({}); + await this.view.init({}) } this.initTime = Date.now(); this.fetchReadingSpeed();