package sync import ( "log" "sync" ) type LocatorSource string const ( LocatorSourceKOReader LocatorSource = "koreader" LocatorSourceKobo LocatorSource = "kobo" LocatorSourceWeb LocatorSource = "web" ) type CanonicalLocator struct { CFI string Precision string Percentage float64 } type DeviceLocator struct { Position string Precision string Percentage float64 } func isConvertible(formatGroup string) bool { return formatGroup == string(FormatGroupReflowable) } // Converters parse and cache the whole EPUB (spine + content docs), so // creating one per annotation re-reads the book for every entry. A small // bounded cache lets one request — or several — share a single parse. // Servers are the right place for this work: clients stay thin. var ( converterMu sync.Mutex converterCache = map[string]*CFIConverter{} converterOrder []string // insertion order for eviction ) const maxCachedConverters = 8 func cachedConverter(epubPath string) *CFIConverter { converterMu.Lock() defer converterMu.Unlock() if c, ok := converterCache[epubPath]; ok { return c } c := NewCFIConverter(epubPath) converterCache[epubPath] = c converterOrder = append(converterOrder, epubPath) for len(converterOrder) > maxCachedConverters { oldest := converterOrder[0] converterOrder = converterOrder[1:] delete(converterCache, oldest) } return c } func ConvertToCanonical( source LocatorSource, devicePos string, percentage float64, contextText string, formatGroup string, epubPath string, kepubPath string, ) CanonicalLocator { if !isConvertible(formatGroup) || epubPath == "" { return CanonicalLocator{ CFI: devicePos, Precision: "passthrough", Percentage: percentage, } } switch source { case LocatorSourceKOReader: if !IsCREXPointer(devicePos) { return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage} } converter := cachedConverter(epubPath) result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText) if err != nil || result == nil { log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err) return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage} } if result.EPUBCFI != "" { return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage} } if result.Href != "" { return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage} } return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage} case LocatorSourceKobo: if kepubPath == "" { return CanonicalLocator{CFI: devicePos, Precision: "no-kepub", Percentage: percentage} } converter := NewKEPUBCFIConverter(epubPath, kepubPath) result, err := converter.ConvertKEPUBCFIToStandard(devicePos, percentage, contextText) if err != nil || result == nil { log.Printf("Bookhoard: locator KEPUB→CFI conversion failed: %v", err) return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage} } if result.CFI != "" { return CanonicalLocator{CFI: result.CFI, Precision: result.Precision, Percentage: result.Percentage} } return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage} default: return CanonicalLocator{CFI: devicePos, Precision: "passthrough", Percentage: percentage} } } func ConvertFromCanonical( source LocatorSource, canonicalCFI string, percentage float64, contextText string, formatGroup string, epubPath string, kepubPath string, ) DeviceLocator { if !isConvertible(formatGroup) || epubPath == "" || canonicalCFI == "" { return DeviceLocator{ Position: canonicalCFI, Precision: "passthrough", Percentage: percentage, } } switch source { case LocatorSourceKOReader: converter := cachedConverter(epubPath) result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText) if err != nil || result == nil { log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err) return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage} } if result.XPointer != "" { return DeviceLocator{Position: result.XPointer, Precision: result.Precision, Percentage: result.Percentage} } return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage} case LocatorSourceKobo: if kepubPath == "" { return DeviceLocator{Position: canonicalCFI, Precision: "no-kepub", Percentage: percentage} } converter := NewKEPUBCFIConverter(epubPath, kepubPath) result, err := converter.ConvertStandardCFIToKEPUB(canonicalCFI, percentage, contextText) if err != nil || result == nil { log.Printf("Bookhoard: locator CFI→KEPUB conversion failed: %v", err) return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage} } if result.CFI != "" { return DeviceLocator{Position: result.CFI, Precision: result.Precision, Percentage: result.Percentage} } return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage} default: return DeviceLocator{Position: canonicalCFI, Precision: "passthrough", Percentage: percentage} } }