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:
2026-01-30 16:07:00 -05:00
parent fa4a9c35bb
commit bb3c32c59f
6 changed files with 3101 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
package sync
import (
"path/filepath"
"strings"
)
// FormatGroup represents the three main format categories
type FormatGroup string
const (
FormatGroupReflowable FormatGroup = "reflowable"
FormatGroupFixedLayout FormatGroup = "fixed_layout"
FormatGroupComicArchive FormatGroup = "comic_archive"
FormatGroupUnknown FormatGroup = "unknown"
)
// MimeType mappings for common ebook formats
var mimeTypes = map[string]string{
".epub": "application/epub+zip",
".mobi": "application/x-mobipocket-ebook",
".azw": "application/x-mobipocket-ebook",
".azw3": "application/vnd.amazon.mobi8-ebook",
".pdf": "application/pdf",
".djvu": "image/vnd.djvu",
".cbz": "application/x-cbz",
".cbr": "application/x-cbr",
".cb7": "application/x-cb7",
".cbt": "application/x-cbt",
".fb2": "application/x-fictionbook+xml",
".txt": "text/plain",
".rtf": "application/rtf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".lit": "application/x-ms-reader",
".pdb": "application/vnd.palm",
".prc": "application/vnd.palm",
}
// ReflowableFormats are formats that support text reflow
var ReflowableFormats = map[string]bool{
".epub": true,
".mobi": true,
".azw": true,
".azw3": true,
".fb2": true,
".txt": true,
".rtf": true,
".doc": true,
".docx": true,
".lit": true,
".pdb": true,
".prc": true,
}
// FixedLayoutFormats are formats with fixed page layouts
var FixedLayoutFormats = map[string]bool{
".pdf": true,
".djvu": true,
}
// ComicArchiveFormats are comic archive formats
var ComicArchiveFormats = map[string]bool{
".cbz": true,
".cbr": true,
".cb7": true,
".cbt": true,
}
// DetectFormatGroup determines the format group based on mimetype and file path
func DetectFormatGroup(mimetype string, filePath string) FormatGroup {
ext := strings.ToLower(filepath.Ext(filePath))
// Check by mimetype first
switch mimetype {
case "application/epub+zip",
"application/x-mobipocket-ebook",
"application/vnd.amazon.mobi8-ebook",
"application/x-fictionbook+xml",
"text/plain":
return FormatGroupReflowable
case "application/pdf",
"image/vnd.djvu":
return FormatGroupFixedLayout
case "application/x-cbr",
"application/x-cbz",
"application/x-cb7",
"application/x-cbt":
return FormatGroupComicArchive
}
// Fall back to file extension
if ReflowableFormats[ext] {
return FormatGroupReflowable
}
if FixedLayoutFormats[ext] {
return FormatGroupFixedLayout
}
if ComicArchiveFormats[ext] {
return FormatGroupComicArchive
}
return FormatGroupUnknown
}
// GetMimeType returns the mimetype for a given file extension
func GetMimeType(filePath string) string {
ext := strings.ToLower(filepath.Ext(filePath))
if mt, ok := mimeTypes[ext]; ok {
return mt
}
return ""
}
// IsReflowable checks if a format is reflowable
func IsReflowable(formatGroup FormatGroup) bool {
return formatGroup == FormatGroupReflowable
}
// HasFixedLayout checks if a format has fixed layout
func HasFixedLayout(formatGroup FormatGroup) bool {
return formatGroup == FormatGroupFixedLayout
}
// IsComicArchive checks if a format is a comic archive
func IsComicArchive(formatGroup FormatGroup) bool {
return formatGroup == FormatGroupComicArchive
}
+255
View File
@@ -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)
}