Files
john-okeefe 762181c123 fix(sync): treat page index as sole locator for fixed-layout pull path
The push path (koreader→server) was already updated to omit epubcfi
for paging documents, and SaveProgress derives percentage from
page/total_pages for fixed formats. However, the pull path
(server→koreader) still returned any stored epubcfi and attempted
CFI→XPointer conversion, and SaveProgress preserved stale CFI values
written by the web reader (which generates fake CFIs via
CFI.fake.fromIndex for every comic/PDF page).

These fake CFI strings lingered in the database and koreader's pull
path picked them up over progress.page, causing tonumber("epubcfi(...)")
→ nil → GotoPage(nil) → crash when the user confirmed the sync prompt.

Changes:
- GetMetadata (koreader.go): for fixed_layout/comic_archive formats,
  skip returning epubcfi and skip the CFI→CRE XPointer conversion.
  Reflowable documents are byte-for-byte unchanged.
- SaveProgress (progress.go): for fixed formats, explicitly clear
  epubcfi and character_offset on every save so stale values from
  prior web-reader sessions are cleaned up over time.
2026-06-07 00:26:53 -04:00

584 lines
17 KiB
Go

package sync
import (
"bookhoard/internal/database"
"context"
"encoding/json"
"fmt"
"log"
"math"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// ProgressData represents universal progress data with multiple location references
type ProgressData struct {
Percentage *float64 `json:"percentage,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
Character *int64 `json:"character,omitempty"`
Chapter *int `json:"chapter,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
ViewportY *float64 `json:"viewport_y,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
PageY *int `json:"page_y,omitempty"`
Zoom *float64 `json:"zoom,omitempty"`
ScrollX *float64 `json:"scroll_x,omitempty"`
ScrollY *float64 `json:"scroll_y,omitempty"`
Panel *int `json:"panel,omitempty"`
ReadingMode *string `json:"reading_mode,omitempty"`
TotalCharacters *int64 `json:"total_characters,omitempty"`
}
// DeviceProgress represents progress from a specific device
type DeviceProgress struct {
Source string `json:"source"`
Data ProgressData `json:"data"`
Timestamp string `json:"timestamp,omitempty"`
}
// ConvertProgress converts progress between different format groups
func ConvertProgress(sourceFormat, targetFormat FormatGroup, sourceData map[string]interface{}) (map[string]interface{}, error) {
percentage := extractPercentage(sourceFormat, sourceData)
result := make(map[string]interface{})
switch targetFormat {
case FormatGroupReflowable:
result["percentage"] = percentage
if epubcfi, ok := sourceData["epubcfi"].(string); ok {
result["epubcfi"] = epubcfi
} else {
// Generate approximate CFI from percentage
result["epubcfi"] = fmt.Sprintf("epubcfi(/6/4/2:%d)", int(percentage*100))
}
if totalChars, ok := sourceData["total_characters"].(int64); ok {
result["character"] = int64(float64(totalChars) * percentage)
}
case FormatGroupFixedLayout:
totalPages := 200.0
if tp, ok := sourceData["total_pages"].(int); ok {
totalPages = float64(tp)
}
result["page"] = int(math.Round(percentage * totalPages))
result["total_pages"] = int(totalPages)
result["percentage"] = percentage
if pageY, ok := sourceData["page_y"].(int); ok {
result["page_y"] = pageY
}
case FormatGroupComicArchive:
totalPages := 32.0
if tp, ok := sourceData["total_pages"].(int); ok {
totalPages = float64(tp)
}
result["page"] = int(math.Round(percentage * totalPages))
result["total_pages"] = int(totalPages)
result["percentage"] = percentage
if panel, ok := sourceData["panel"].(int); ok {
result["panel"] = panel
}
default:
return nil, fmt.Errorf("unsupported target format: %s", targetFormat)
}
return result, nil
}
// extractPercentage extracts the percentage (0.0-1.0) from source data
func extractPercentage(sourceFormat FormatGroup, sourceData map[string]interface{}) float64 {
switch sourceFormat {
case FormatGroupReflowable:
if p, ok := sourceData["percentage"].(float64); ok {
return p
}
// Try to calculate from character offset
if char, ok := sourceData["character"].(int64); ok {
if total, ok := sourceData["total_characters"].(int64); ok && total > 0 {
return float64(char) / float64(total)
}
}
case FormatGroupFixedLayout, FormatGroupComicArchive:
if page, ok := sourceData["page"].(int); ok {
if total, ok := sourceData["total_pages"].(int); ok && total > 0 {
return float64(page) / float64(total)
}
}
// Try direct percentage
if p, ok := sourceData["percentage"].(float64); ok {
return p
}
}
return 0.0
}
// PageToPercentage converts page/total_pages to percentage
func PageToPercentage(page, totalPages int) float64 {
if totalPages <= 0 {
return 0.0
}
percentage := float64(page) / float64(totalPages)
if percentage > 1.0 {
percentage = 1.0
}
if percentage < 0.0 {
percentage = 0.0
}
return percentage
}
// PercentageToPage converts percentage to page number
func PercentageToPage(percentage float64, totalPages int) int {
if percentage < 0.0 {
percentage = 0.0
}
if percentage > 1.0 {
percentage = 1.0
}
page := int(math.Round(float64(totalPages) * percentage))
if page < 0 {
page = 0
}
if page > totalPages {
page = totalPages
}
return page
}
// CharacterToPercentage converts character offset to percentage
func CharacterToPercentage(character, totalCharacters int64) float64 {
if totalCharacters <= 0 {
return 0.0
}
percentage := float64(character) / float64(totalCharacters)
if percentage > 1.0 {
percentage = 1.0
}
if percentage < 0.0 {
percentage = 0.0
}
return percentage
}
// PercentageToCharacter converts percentage to character offset
func PercentageToCharacter(percentage float64, totalCharacters int64) int64 {
if percentage < 0.0 {
percentage = 0.0
}
if percentage > 1.0 {
percentage = 1.0
}
char := int64(math.Round(float64(totalCharacters) * percentage))
if char < 0 {
char = 0
}
if char > totalCharacters {
char = totalCharacters
}
return char
}
// MergeProgress merges progress from two sources using "max progress wins" strategy
func MergeProgress(progressA, progressB map[string]interface{}) map[string]interface{} {
percA := extractPercentage(FormatGroupReflowable, progressA)
percB := extractPercentage(FormatGroupReflowable, progressB)
winner := progressB
if percA > percB {
winner = progressA
}
result := make(map[string]interface{})
for k, v := range winner {
result[k] = v
}
sources := []string{}
if srcA, ok := progressA["source"].(string); ok {
sources = append(sources, srcA)
}
if srcB, ok := progressB["source"].(string); ok {
sources = append(sources, srcB)
}
result["merged_from"] = sources
result["merge_timestamp"] = "now"
return result
}
// FormatProgressForDisplay formats progress for display based on format group
func FormatProgressForDisplay(formatGroup FormatGroup, progress map[string]interface{}) string {
percentage := extractPercentage(formatGroup, progress)
switch formatGroup {
case FormatGroupReflowable:
if chapter, ok := progress["chapter"].(int); ok {
return fmt.Sprintf("%.1f%% (Chapter %d)", percentage*100, chapter)
}
return fmt.Sprintf("%.1f%%", percentage*100)
case FormatGroupFixedLayout:
if page, ok := progress["page"].(int); ok {
if total, ok := progress["total_pages"].(int); ok {
return fmt.Sprintf("Page %d of %d (%.1f%%)", page, total, percentage*100)
}
return fmt.Sprintf("Page %d (%.1f%%)", page, percentage*100)
}
return fmt.Sprintf("%.1f%%", percentage*100)
case FormatGroupComicArchive:
if page, ok := progress["page"].(int); ok {
if total, ok := progress["total_pages"].(int); ok {
return fmt.Sprintf("Page %d of %d (%.0f%%)", page, total, percentage*100)
}
return fmt.Sprintf("Page %d (%.0f%%)", page, percentage*100)
}
return fmt.Sprintf("%.0f%%", percentage*100)
default:
return fmt.Sprintf("%.1f%%", percentage*100)
}
}
// ParseProgressFromJSON parses progress data from JSON
func ParseProgressFromJSON(data []byte) (*ProgressData, error) {
var progress ProgressData
err := json.Unmarshal(data, &progress)
if err != nil {
return nil, err
}
return &progress, nil
}
// MarshalProgressToJSON converts progress data to JSON
func MarshalProgressToJSON(progress *ProgressData) ([]byte, error) {
return json.Marshal(progress)
}
const CharsPerPage int64 = 1800
func EstimatedPages(totalCharacters int64) int {
if totalCharacters <= 0 {
return 0
}
return int(totalCharacters / CharsPerPage)
}
type ProgressService struct {
db *database.Queries
connManager *ConnectionManager
}
func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService {
return &ProgressService{db: db, connManager: connManager}
}
type SaveProgressRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Source string
DeviceID pgtype.UUID
Percentage *float64
Epubcfi *string
ContextText *string
CharacterOffset *int64
Chapter *int
ChapterProgress *float64
CurrentPage *int
TotalPages *int
ViewportX *float64
ViewportY *float64
ZoomLevel *float64
ScrollX *float64
ScrollY *float64
PanelNumber *int
ReadingMode *string
DeviceType string
DeviceName string
Broadcast bool
}
func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequest) (database.ReadingProgress, error) {
if req.Source == "" {
req.Source = "unknown"
}
mediaItem, err := s.db.GetMediaItem(ctx, req.MediaItemID)
if err != nil {
return database.ReadingProgress{}, fmt.Errorf("media item not found: %w", err)
}
existing, err := s.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
})
hasExisting := err == nil
if err != nil && err != pgx.ErrNoRows {
return database.ReadingProgress{}, fmt.Errorf("failed to read existing progress: %w", err)
}
params := database.UpdateUniversalProgressParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
}
if hasExisting {
params.Percentage = existing.Percentage
params.CharacterOffset = existing.CharacterOffset
params.Epubcfi = existing.Epubcfi
params.ContextText = existing.ContextText
params.Chapter = existing.Chapter
params.ChapterProgress = existing.ChapterProgress
params.ViewportX = existing.ViewportX
params.ViewportY = existing.ViewportY
params.ZoomLevel = existing.ZoomLevel
params.ScrollPositionX = existing.ScrollPositionX
params.ScrollPositionY = existing.ScrollPositionY
params.PanelNumber = existing.PanelNumber
params.ReadingMode = existing.ReadingMode
params.CurrentPage = existing.CurrentPage
params.TotalPages = existing.TotalPages
params.LastSyncDevice = existing.LastSyncDevice
params.LastSyncSource = existing.LastSyncSource
}
if req.Percentage != nil {
params.Percentage = pgtype.Float8{Float64: *req.Percentage, Valid: true}
}
if req.Epubcfi != nil {
params.Epubcfi = pgtype.Text{String: *req.Epubcfi, Valid: *req.Epubcfi != ""}
}
if req.ContextText != nil {
params.ContextText = pgtype.Text{String: *req.ContextText, Valid: *req.ContextText != ""}
}
if req.CharacterOffset != nil {
params.CharacterOffset = pgtype.Int8{Int64: *req.CharacterOffset, Valid: true}
}
if req.Chapter != nil {
params.Chapter = pgtype.Int4{Int32: int32(*req.Chapter), Valid: true}
}
if req.ChapterProgress != nil {
params.ChapterProgress = pgtype.Float8{Float64: *req.ChapterProgress, Valid: true}
}
if req.CurrentPage != nil {
params.CurrentPage = pgtype.Int4{Int32: int32(*req.CurrentPage), Valid: true}
}
if req.TotalPages != nil {
params.TotalPages = pgtype.Int4{Int32: int32(*req.TotalPages), Valid: true}
}
if req.ViewportX != nil {
params.ViewportX = pgtype.Float8{Float64: *req.ViewportX, Valid: true}
}
if req.ViewportY != nil {
params.ViewportY = pgtype.Float8{Float64: *req.ViewportY, Valid: true}
}
if req.ZoomLevel != nil {
params.ZoomLevel = pgtype.Float8{Float64: *req.ZoomLevel, Valid: true}
}
if req.ScrollX != nil {
params.ScrollPositionX = pgtype.Float8{Float64: *req.ScrollX, Valid: true}
}
if req.ScrollY != nil {
params.ScrollPositionY = pgtype.Float8{Float64: *req.ScrollY, Valid: true}
}
if req.PanelNumber != nil {
params.PanelNumber = pgtype.Int4{Int32: int32(*req.PanelNumber), Valid: true}
}
if req.ReadingMode != nil {
params.ReadingMode = pgtype.Text{String: *req.ReadingMode, Valid: true}
}
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
formatGroup := FormatGroup(mediaItem.FormatGroup)
isFixed := formatGroup == FormatGroupFixedLayout || formatGroup == FormatGroupComicArchive
if isFixed {
// Fixed-layout & comic formats: the page index is the canonical locator.
// CFI/character-offset are meaningless for image-based content, so clear
// any stale value that may have been stored by the web reader (which
// generates fake CFIs for comics).
params.Epubcfi = pgtype.Text{}
params.CharacterOffset = pgtype.Int8{}
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
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
if existing.LastSyncTimestamp.Valid {
timeDiff := time.Since(existing.LastSyncTimestamp.Time)
if timeDiff < 5*time.Minute {
existingPct := 0.0
if existing.Percentage.Valid {
existingPct = existing.Percentage.Float64
}
newPct := 0.0
if params.Percentage.Valid {
newPct = params.Percentage.Float64
}
diff := newPct - existingPct
if diff < 0 {
diff = -diff
}
if diff > 0.01 {
recentlyResolved, _ := s.db.HasRecentConflictResolution(ctx, database.HasRecentConflictResolutionParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
})
if !recentlyResolved {
conflictDetected = true
}
}
}
}
}
result, err := s.db.UpdateUniversalProgress(ctx, params)
if err != nil {
return database.ReadingProgress{}, fmt.Errorf("failed to upsert progress: %w", err)
}
if conflictDetected {
conflictData := map[string]interface{}{
req.Source: map[string]interface{}{
"source": req.Source,
"timestamp": time.Now().Format(time.RFC3339),
"data": buildProgressSnapshot(req),
},
existing.LastSyncSource.String: map[string]interface{}{
"source": existing.LastSyncSource.String,
"timestamp": existing.LastSyncTimestamp.Time.Format(time.RFC3339),
"data": map[string]interface{}{
"percentage": float64Ptr(existing.Percentage),
"epubcfi": textPtr(existing.Epubcfi),
"chapter": int32Ptr(existing.Chapter),
"character": int64Ptr(existing.CharacterOffset),
"page": int32Ptr(existing.CurrentPage),
"total_pages": int32Ptr(existing.TotalPages),
},
},
}
conflictJSON, _ := json.Marshal(conflictData)
_, err := s.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
ConflictType: "progress",
ConflictData: conflictJSON,
})
if err != nil {
log.Printf("Failed to record sync conflict: %v", err)
} else if s.connManager != nil {
s.connManager.BroadcastConflictNotification(req.MediaItemID.Bytes, "detection", "")
}
}
if s.connManager != nil && req.Broadcast {
deviceName := req.DeviceName
if deviceName == "" {
deviceName = req.Source + " Device"
}
pct := 0.0
if result.Percentage.Valid {
pct = result.Percentage.Float64
}
s.connManager.BroadcastProgressUpdate(
uuid.UUID(req.MediaItemID.Bytes),
pct,
SourceDevice{
ID: uuid.UUID(req.DeviceID.Bytes).String(),
Name: deviceName,
Type: req.Source,
},
)
}
return result, nil
}
func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
data := map[string]interface{}{}
if req.Percentage != nil {
data["percentage"] = *req.Percentage
}
if req.Epubcfi != nil {
data["epubcfi"] = *req.Epubcfi
}
if req.ContextText != nil {
data["context_text"] = *req.ContextText
}
if req.Chapter != nil {
data["chapter"] = *req.Chapter
}
if req.CharacterOffset != nil {
data["character"] = *req.CharacterOffset
}
if req.CurrentPage != nil {
data["page"] = *req.CurrentPage
}
if req.TotalPages != nil {
data["total_pages"] = *req.TotalPages
}
return data
}
func float64Ptr(v pgtype.Float8) *float64 {
if v.Valid {
return &v.Float64
}
return nil
}
func textPtr(v pgtype.Text) *string {
if v.Valid {
return &v.String
}
return nil
}
func int32Ptr(v pgtype.Int4) *int {
if v.Valid {
val := int(v.Int32)
return &val
}
return nil
}
func int64Ptr(v pgtype.Int8) *int64 {
if v.Valid {
return &v.Int64
}
return nil
}