diff --git a/internal/router/reader.go b/internal/router/reader.go index e067b8e..0e0d89d 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -1,18 +1,15 @@ package router import ( - "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" "bookhoard/internal/sync" "bookhoard/internal/utils" "bookhoard/templates" "bytes" - "errors" "net/http" "github.com/google/uuid" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) @@ -69,21 +66,10 @@ func registerReaderRoutes(cfg *Config) { if !visible { return renderErrorPage(c, "Access denied", "access_denied") } - // Get reading progress - var progress database.ReadingProgress - progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ - MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, - UserID: uuidToPGType(userUUID), - }) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - progress = database.ReadingProgress{} - } - // Get bookmarks - bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ - MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, - UserID: uuidToPGType(userUUID), - }) - // Convert to template types + // Convert to template types. Reading state is deliberately NOT + // fetched or embedded: the reader pulls position, bookmarks, and + // annotations from the APIs at open time so the page can never + // carry (nor write back) a stale snapshot. mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16]) libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16]) metadata := templates.ReaderMetadata{ @@ -104,58 +90,9 @@ func registerReaderRoutes(cfg *Config) { TotalCharacters: mediaItem.TotalCharacters.Int64, EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64), } - // Progress conversion (inline) - progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16]) - progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16]) - progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16]) - templateProgress := templates.ReadingProgress{ - ID: progressUUID.String(), - MediaItemID: progressMediaUUID.String(), - UserID: progressUserUUID.String(), - CurrentPage: int(progress.CurrentPage.Int32), - TotalPages: int(progress.TotalPages.Int32), - Percentage: progress.Percentage.Float64 * 100, - EpubCfi: textToString(progress.Epubcfi), - LastReadAt: progress.LastReadAt.Time, - Chapter: int(progress.Chapter.Int32), - ChapterProgress: progress.ChapterProgress.Float64 * 100, - FormatGroup: mediaItem.FormatGroup, - } - // Bookmarks conversion (inline, with loop) - templateBookmarks := make([]templates.Bookmark, len(bookmarks)) - for i, b := range bookmarks { - bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16]) - bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16]) - bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16]) - - var pageNumber *int - if b.PageNumber.Valid { - val := int(b.PageNumber.Int32) - pageNumber = &val - } - - var chapterNumber *int - if b.ChapterNumber.Valid { - val := int(b.ChapterNumber.Int32) - chapterNumber = &val - } - - templateBookmarks[i] = templates.Bookmark{ - ID: bookmarkUUID.String(), - MediaItemID: bookmarkMediaUUID.String(), - UserID: bookmarkUserUUID.String(), - PageNumber: pageNumber, - ChapterNumber: chapterNumber, - CfiPosition: textToString(b.CfiPosition), - Title: b.Title, - Position: textToString(b.Position), - Notes: textToString(b.Notes), - CreatedAt: b.CreatedAt.Time, - } - } - // 8. Render template + // Render template var buf bytes.Buffer - err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf) + err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf) if err != nil { return renderErrorPage(c, "Error rendering reader", "render_error") } diff --git a/templates/reader.templ b/templates/reader.templ index 39c46ca..e866618 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -5,7 +5,11 @@ import ( "fmt" ) -func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string { +// The init config carries only immutable book metadata. Reading state +// (position, bookmarks, annotations) is never embedded: the reader fetches +// it from the APIs at open time, so the page can never carry — nor write +// back — a stale snapshot of it. +func readerInitExpr(metadata ReaderMetadata) string { config := map[string]interface{}{ "mediaItemId": metadata.MediaItemID, "fileUrl": metadata.FileURL, @@ -13,42 +17,11 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks "readingDirection": metadata.ReadingDirection, "mangaType": metadata.MangaType, } - if progress.Percentage > 0 { - config["savedPercentage"] = progress.Percentage / 100 - } - 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 - } - } - if len(bookmarks) > 0 { - items := make([]map[string]interface{}, 0, len(bookmarks)) - for _, b := range bookmarks { - var page any - if b.PageNumber != nil { - page = *b.PageNumber - } - items = append(items, map[string]interface{}{ - "id": b.ID, - "title": b.Title, - "positionLabel": b.Position, - "cfi": b.CfiPosition, - "page": page, - }) - } - config["bookmarks"] = items - } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) } -templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) { +templ Reader(user User, metadata ReaderMetadata) { @@ -64,7 +37,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
} -templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) { +templ ReaderChrome(metadata ReaderMetadata) {
@@ -365,17 +338,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
- - if progress.FormatGroup == "reflowable" { - if metadata.EstimatedPages > 0 { - { fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) } - } else { - { fmt.Sprintf("%.0f%%", progress.Percentage) } - } - } else { - { fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) } - } - +
@@ -467,13 +430,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
- - if progress.FormatGroup == "reflowable" { - { fmt.Sprintf("%.0f%%", progress.Percentage) } - } else { - { fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) } - } - +
diff --git a/templates/reader_templ.go b/templates/reader_templ.go index 32b0d6a..14ffcdc 100644 --- a/templates/reader_templ.go +++ b/templates/reader_templ.go @@ -13,7 +13,11 @@ import ( "fmt" ) -func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string { +// The init config carries only immutable book metadata. Reading state +// (position, bookmarks, annotations) is never embedded: the reader fetches +// it from the APIs at open time, so the page can never carry — nor write +// back — a stale snapshot of it. +func readerInitExpr(metadata ReaderMetadata) string { config := map[string]interface{}{ "mediaItemId": metadata.MediaItemID, "fileUrl": metadata.FileURL, @@ -21,42 +25,11 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks "readingDirection": metadata.ReadingDirection, "mangaType": metadata.MangaType, } - if progress.Percentage > 0 { - config["savedPercentage"] = progress.Percentage / 100 - } - 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 - } - } - if len(bookmarks) > 0 { - items := make([]map[string]interface{}, 0, len(bookmarks)) - for _, b := range bookmarks { - var page any - if b.PageNumber != nil { - page = *b.PageNumber - } - items = append(items, map[string]interface{}{ - "id": b.ID, - "title": b.Title, - "positionLabel": b.Position, - "cfi": b.CfiPosition, - "page": page, - }) - } - config["bookmarks"] = items - } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) } -func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) templ.Component { +func Reader(user User, metadata ReaderMetadata) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -84,7 +57,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: 57, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 30, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -104,9 +77,9 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata, progress, bookmarks)) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 67, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 40, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -129,7 +102,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = ReaderChrome(metadata, progress).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ReaderChrome(metadata).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -173,7 +146,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma }) } -func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Component { +func ReaderChrome(metadata ReaderMetadata) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -201,7 +174,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, 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: 319, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 292, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -214,75 +187,13 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 322, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 295, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if progress.FormatGroup == "reflowable" { - if metadata.EstimatedPages > 0 { - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, 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: 371, Col: 114} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, 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: 373, Col: 53} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - } else { - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, 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: 376, Col: 73} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if progress.FormatGroup == "reflowable" { - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, 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: 472, Col: 52} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, 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: 474, Col: 73} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
0/0
Zoom
Fit
Page position
Magnifier
Night mode
Pointer
Double page
Contents
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
0/0
Zoom
Fit
Page position
Magnifier
Night mode
Pointer
Double page
Contents
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -306,25 +217,25 @@ func drawerHeader(title string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(title) + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 598, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 555, Col: 35} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -348,16 +259,16 @@ func ReaderTOCDrawer() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var16 := templ.GetChildren(ctx) - if templ_7745c5c3_Var16 == nil { - templ_7745c5c3_Var16 = templ.NopComponent + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = drawerHeader("Contents").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -381,16 +292,16 @@ func ReaderAnnotationsDrawer() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var17 := templ.GetChildren(ctx) - if templ_7745c5c3_Var17 == nil { - templ_7745c5c3_Var17 = templ.NopComponent + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = drawerHeader("Annotations").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -414,16 +325,16 @@ func ReaderSearchDrawer() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = templ.NopComponent + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = drawerHeader("Search").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Searching… 0 matches No matches Search failed
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Searching… 0 matches No matches Search failed
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -447,16 +358,16 @@ func ReaderSettingsDrawer() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var19 := templ.GetChildren(ctx) - if templ_7745c5c3_Var19 == nil { - templ_7745c5c3_Var19 = templ.NopComponent + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = drawerHeader("Settings").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

Behavior

Reading Theme

Typography

Layout & Display

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Behavior

Reading Theme

Typography

Layout & Display

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 0c62f5c..59c2358 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -482,7 +482,11 @@ document.addEventListener("alpine:init", () => { tocItems: [] as any[], mediaItemId: "" as string, saveTimeout: null as ReturnType | null, - initTime: 0 as number, + // Set only by deliberate navigation (page turns, jumps, slider). The + // restore at open time and section-load relocations never set it, so + // progress saves can only ever write a position the user actually + // moved to — never a stale restore clobbering a newer device push. + userMoved: false as boolean, contextText: "" as string, readingTheme: "light" as string, readingMode: "light" as string, @@ -564,20 +568,13 @@ document.addEventListener("alpine:init", () => { formatGroup: string; readingDirection: string; mangaType: string; - savedPercentage?: number; - savedCfi?: string; - savedPage?: number; - savedTotalPages?: number; - bookmarks?: { - id: string; - title: string; - positionLabel: string; - cfi: string; - page: number | null; - }[]; }) { this.mediaItemId = config.mediaItemId; - this.bookmarkItems = config.bookmarks ?? []; + // Reading state (position, bookmarks, annotations) is never baked + // into the rendered page: the web reader is intrinsically tied to + // the server, so it reads all of it from the APIs at open time — + // a device sync between render and open can never be shadowed by a + // stale snapshot. this.isComic = config.formatGroup === "comic_archive"; // Reading flow for comics is a per-book preference (a webtoon title // vs. a paged manga volume); read before the renderer is chosen. @@ -916,15 +913,18 @@ document.addEventListener("alpine:init", () => { document.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); - if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) { + // Reading position comes from the database, fetched fresh at open + // (the rendered page carries no snapshot of it). + const saved = await this.fetchSavedLocation(); + if (this.isFixedLayout && saved.page != null && saved.page > 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: saved.page - 1 }) + } else if (saved.cfi) { + await this.view.init({ lastLocation: saved.cfi }) + } else if (saved.percentage != null && saved.percentage > 0) { await this.view.init({ - lastLocation: { fraction: config.savedPercentage }, + lastLocation: { fraction: saved.percentage }, }) } else { await this.view.init({}) @@ -938,9 +938,14 @@ document.addEventListener("alpine:init", () => { this.renderer.setAttribute("interaction-mode", this.interactionMode); } this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null; - this.initTime = Date.now(); + // A bfcache-resurrected page is stale by definition: forbid it from + // writing its frozen position back until the user navigates again. + window.addEventListener("pageshow", (e: PageTransitionEvent) => { + if (e.persisted) this.userMoved = false; + }); this.fetchReadingSpeed(); this.refreshAnnotations(); + this.refreshBookmarks(); this.setupChrome(); this.setupTapZones(); }, @@ -1536,8 +1541,44 @@ document.addEventListener("alpine:init", () => { /* ignore note errors */ } }, + // Fresh reading position from the database — the single source of + // truth at open time. Fails soft to a fresh start: the userMoved gate + // guarantees merely opening (even at the wrong spot) can never + // overwrite the stored position. + async fetchSavedLocation(): Promise<{ + cfi?: string; + page?: number; + percentage?: number; + }> { + const token = getToken(); + if (!token || !this.mediaItemId) return {}; + try { + const resp = await fetch( + `/api/media-items/${this.mediaItemId}/progress`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }, + ); + if (!resp.ok) return {}; + const row: any = await resp.json(); + const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? ""; + const page: number = row?.current_page?.Int32 ?? row?.current_page ?? 0; + // The stored percentage is a 0-1 fraction. + const pct: number = row?.percentage?.Float64 ?? row?.percentage ?? 0; + return { + cfi: typeof cfi === "string" ? cfi : "", + page: typeof page === "number" ? page : 0, + percentage: typeof pct === "number" ? pct : 0, + }; + } catch (_e) { + return {}; + } + }, debouncedSaveProgress(fraction: number, location: any, cfi: string) { - if (Date.now() - this.initTime < 5000) return; + // Only deliberate navigation writes progress: displaying a restored + // position must never overwrite a newer device push. + if (!this.userMoved) return; if (this.saveTimeout) clearTimeout(this.saveTimeout); this.saveTimeout = setTimeout(() => { this.saveProgress(fraction, location, cfi); @@ -1678,18 +1719,23 @@ document.addEventListener("alpine:init", () => { saveSettings({ double_page_spread: this.doublePageSpread }); }, goLeft() { + this.userMoved = true; this.view?.goLeft?.(); }, goRight() { + this.userMoved = true; this.view?.goRight?.(); }, nextPage() { + this.userMoved = true; this.view?.next?.(); }, previousPage() { + this.userMoved = true; this.view?.prev?.(); }, goToFraction(value: string) { + this.userMoved = true; this.view?.goToFraction?.(parseFloat(value)); }, toggleTOC() { @@ -1868,9 +1914,11 @@ document.addEventListener("alpine:init", () => { }, goToSearchResult(item: { cfi?: string; page?: number | null }) { if (item.cfi) { + this.userMoved = true; this.pushBackStack(); this.view?.goTo?.(item.cfi); } else if (item.page != null) { + this.userMoved = true; this.pushBackStack(); this.view?.goTo?.(item.page); } else return; @@ -1899,6 +1947,7 @@ document.addEventListener("alpine:init", () => { goBackToLocation() { const loc = this.backStack.pop(); if (!loc) return; + this.userMoved = true; if (loc.cfi) this.view?.goTo?.(loc.cfi); else if (typeof loc.page === "number") this.view?.goTo?.(loc.page); }, @@ -1914,6 +1963,7 @@ document.addEventListener("alpine:init", () => { }, goToTOCItem(item: any) { if (this.view && item.href) { + this.userMoved = true; this.pushBackStack(); this.view.goTo(item.href); this.tocOpen = false; @@ -2041,6 +2091,7 @@ document.addEventListener("alpine:init", () => { }, goToPage(index: number) { if (!this.view || typeof index !== "number" || index < 0) return; + this.userMoved = true; this.pushBackStack(); this.view.goTo(index); this.tocOpen = false; @@ -2048,9 +2099,11 @@ document.addEventListener("alpine:init", () => { goToBookmark(item: { cfi: string; page: number | null }) { if (!this.view) return; if (item.cfi) { + this.userMoved = true; this.pushBackStack(); this.view.goTo(item.cfi); } else if (item.page != null && item.page > 0) { + this.userMoved = true; this.pushBackStack(); // Fixed-layout/comic: sections are pages; foliate takes an index. this.view.goTo(item.page - 1);