Tonight's failures all traced to one blind spot: the converter could
only reason about text within a single block. A position at a chapter
heading sends walk-up context (heading + the paragraphs below, joined by
the plugin's block capture); a selection can span several paragraphs.
Neither shape could be verified (containment compared one block against
a multi-block quote, so the CORRECT structural landing at the heading
was rejected) nor matched by text search (it never crossed block
boundaries). The ladder then fell to the percentage rung — which labeled
its char-count guess Precision "exact" — and that confidently-wrong CFI
was stored: reading positions reopened paragraphs away from the true
spot, and a highlight echo overwrote the row's good web CFIs with a
garbage start anchor that made the highlight unpaintable ("disappeared").
Four changes, all in the forward converter and its consumers:
- Quote verification: after the structural walk lands, read the
whitespace-normalized document text forward from the landing point
(crossing block boundaries; inline spans join directly so drop-cap
splits still read as one word). A usable context must be a prefix of
that stream — which is exactly what device captures are: the text from
the position onward, or the selection between two anchors. The old
single-block containment checks remain as secondary acceptance.
- Cross-block text search: the search rung matches against the whole
document flattened in reading order, with every rune mapped back to
its source node and offset. A context spanning blocks now matches, and
the matched extent yields a true range end (EndEPUBCFI) that
highlights use as their end anchor, threaded through the facade as
CanonicalLocator.EndCFI.
- Honest labels: the percentage rung returns Precision "percentage" —
a char-count estimate must never masquerade as an exact anchor.
- Confident-only storage: progress adopts a converted locator solely at
structural/exact precision (section hrefs keep their legacy handling;
anything lower stores percentage only), and highlight conversion
returns CFIs only at structural/exact precision — a low-confidence
echo yields empty, which applyLWW coalescing turns into preservation
of the row's existing web CFIs instead of clobbering them.
Tests: walk-up context at a heading verifies structurally and lands in
the heading; a block-spanning context is found by search with a range
end landing in the following paragraph; the percentage rung is honestly
labeled; all drop-cap guards stay green.
177 lines
5.7 KiB
Go
177 lines
5.7 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
|
|
// EndCFI carries the matched context's range end (text-search
|
|
// conversions of selections) so callers can anchor a true highlight
|
|
// range across block boundaries.
|
|
EndCFI 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
|
|
}
|
|
|
|
// SectionPercentageCached resolves the spine-section percentage of a CRE
|
|
// xpointer through the shared bounded converter cache, so per-annotation
|
|
// lookups parse the EPUB once per book instead of once per annotation.
|
|
func SectionPercentageCached(epubPath, xpointer string) float64 {
|
|
return cachedConverter(epubPath).SectionPercentage(xpointer)
|
|
}
|
|
|
|
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, EndCFI: result.EndEPUBCFI, 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}
|
|
}
|
|
}
|