Files
bookhoard/internal/sync/locators.go
T
john-okeefe 1585aa1073 perf(sync): share parsed EPUBs across conversions, make converters concurrency-safe
ConvertToCanonical/ConvertFromCanonical built a fresh CFIConverter
per call, and each annotation converts twice (pos0+pos1) — a book
with 200 highlights re-opened and re-parsed the EPUB 400+ times per
sync, and again per metadata pull. A bounded 8-entry cache keyed by
path now shares converters (the parsing work belongs on the server;
clients stay thin). CFIConverter gained a mutex around its lazily
built spine/doc caches since instances are now shared between
concurrent requests.

Adds CFIConverter.SectionPercentage: book-wide percentage for a CRE
xpointer from the spine char distribution (midpoint of its document)
— the server-side counterpart to dropping per-annotation
getPageFromXPointer lookups from the plugin.
2026-08-18 19:13:51 -04:00

166 lines
5.2 KiB
Go

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}
}
}