Files
bookhoard/internal/sync/progress.go
T
john-okeefe 89b0b93ffc fix: correct JSON struct tags in ProgressData
Fix incorrect struct tags for Page, TotalPages, and PageY fields.
Previously used 'int' tag instead of proper JSON field names,
which would cause serialization issues.
2026-03-05 17:13:15 -05:00

256 lines
7.2 KiB
Go

package sync
import (
"encoding/json"
"fmt"
"math"
)
// 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)
}