Phase 1 Week 2: Format detection and progress conversion engine
- Add internal/sync package with format detection - FormatGroup types: reflowable, fixed_layout, comic_archive - DetectFormatGroup() function based on mimetype and file extension - MimeType mappings for common ebook formats - Progress conversion engine with: - ConvertProgress() between format groups - Extract percentage from various progress formats - PageToPercentage / PercentageToPage helpers - CharacterToPercentage / PercentageToCharacter helpers - MergeProgress() with 'max progress wins' strategy - FormatProgressForDisplay() for UI rendering - Add sqlc queries for format detection and progress updates - BulkUpdateFormatGroups query for auto-format detection - GetUniversalProgress query with all location references - UpdateUniversalProgress query with device sync metadata - ReadingHistory queries for session tracking
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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 `int,omitempty"`
|
||||
TotalPages *int `int,omitempty"`
|
||||
PageY *int `int,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)
|
||||
}
|
||||
Reference in New Issue
Block a user