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.
This commit is contained in:
2026-06-07 00:03:46 -04:00
parent b1d2ccc87c
commit 423a1c0bf7
5 changed files with 82 additions and 29 deletions
+16
View File
@@ -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,
+30 -15
View File
@@ -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
+8
View File
@@ -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))
}
+18 -10
View File
@@ -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 {
+10 -4
View File
@@ -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();