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.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
@@ -19,6 +20,9 @@ import (
|
|||||||
type CFIConverter struct {
|
type CFIConverter struct {
|
||||||
epubPath string
|
epubPath string
|
||||||
cache *spineCache
|
cache *spineCache
|
||||||
|
// mu guards the lazily-built spine/doc caches: converter instances are
|
||||||
|
// shared across concurrent requests via the package cache in locators.go.
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type spineItem struct {
|
type spineItem struct {
|
||||||
@@ -37,6 +41,8 @@ func NewCFIConverter(epubPath string) *CFIConverter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *CFIConverter) loadSpine() (*spineCache, error) {
|
func (c *CFIConverter) loadSpine() (*spineCache, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if c.cache != nil {
|
if c.cache != nil {
|
||||||
return c.cache, nil
|
return c.cache, nil
|
||||||
}
|
}
|
||||||
@@ -94,6 +100,8 @@ func (c *CFIConverter) getContentDoc(fragmentIndex int) (*html.Node, string, err
|
|||||||
item := spine.items[spineIndex]
|
item := spine.items[spineIndex]
|
||||||
href := item.href
|
href := item.href
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if cached, ok := spine.docCache[href]; ok {
|
if cached, ok := spine.docCache[href]; ok {
|
||||||
return cached, href, nil
|
return cached, href, nil
|
||||||
}
|
}
|
||||||
@@ -243,6 +251,46 @@ type ConversionResult struct {
|
|||||||
Precision string
|
Precision string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SectionPercentage derives an approximate book-wide percentage for a CRE
|
||||||
|
// xpointer from the char distribution across the spine: the midpoint of the
|
||||||
|
// document it points into. Precision is per-section, which is what
|
||||||
|
// percentage_start is used for (ordering/filtering) — and it lets thin
|
||||||
|
// clients skip their own per-annotation page lookups entirely.
|
||||||
|
func (c *CFIConverter) SectionPercentage(xpointer string) float64 {
|
||||||
|
xp, err := ParseCREXPointer(xpointer)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
spine, err := c.loadSpine()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
charCounts := make([]int, len(spine.items))
|
||||||
|
for i := range spine.items {
|
||||||
|
doc, _, docErr := c.getContentDoc(i + 1)
|
||||||
|
if docErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b := findBody(doc); b != nil {
|
||||||
|
charCounts[i] = countTextChars(b)
|
||||||
|
total += charCounts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
idx := xp.FragmentIndex - 1
|
||||||
|
if idx < 0 || idx >= len(spine.items) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
before := 0
|
||||||
|
for i := 0; i < idx; i++ {
|
||||||
|
before += charCounts[i]
|
||||||
|
}
|
||||||
|
return (float64(before) + float64(charCounts[idx])/2) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) {
|
func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) {
|
||||||
if IsCREFragmentID(xpointer) {
|
if IsCREFragmentID(xpointer) {
|
||||||
return c.convertFragmentID(xpointer, storedPercentage)
|
return c.convertFragmentID(xpointer, storedPercentage)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import "log"
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
type LocatorSource string
|
type LocatorSource string
|
||||||
|
|
||||||
@@ -26,6 +29,35 @@ func isConvertible(formatGroup string) bool {
|
|||||||
return formatGroup == string(FormatGroupReflowable)
|
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(
|
func ConvertToCanonical(
|
||||||
source LocatorSource,
|
source LocatorSource,
|
||||||
devicePos string,
|
devicePos string,
|
||||||
@@ -48,7 +80,7 @@ func ConvertToCanonical(
|
|||||||
if !IsCREXPointer(devicePos) {
|
if !IsCREXPointer(devicePos) {
|
||||||
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
||||||
}
|
}
|
||||||
converter := NewCFIConverter(epubPath)
|
converter := cachedConverter(epubPath)
|
||||||
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
||||||
@@ -101,7 +133,7 @@ func ConvertFromCanonical(
|
|||||||
|
|
||||||
switch source {
|
switch source {
|
||||||
case LocatorSourceKOReader:
|
case LocatorSourceKOReader:
|
||||||
converter := NewCFIConverter(epubPath)
|
converter := cachedConverter(epubPath)
|
||||||
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user