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 }