Files
bookhoard/internal/sync/kepub_cfi_converter.go
T
john-okeefe f6d98dd7cc feat(sync): convert KEPUB CFI to standard and extract context_text on Kobo push
When a Kobo device pushes a last-read-place bookmark, the server now
converts the KEPUB CFI (with koboSpan wrappers) to a standard EPUB CFI
and extracts surrounding text as context_text for use by other devices
(KOReader, web reader) during their pull-side CFI conversions.

Previously the raw KEPUB CFI was stored verbatim as epubcfi, which
meant foliate and CREngine couldn't resolve it (wrong child indices
due to koboSpan wrappers), and no context_text was available for the
text-search fallback in ConvertStandardToCRE.

Changes:
- kepub_cfi_converter.go: Add ExtractedContext field to
  KEPUBConversionResult, populated from the already-computed
  searchText in both ConvertKEPUBCFIToStandard and
  ConvertStandardCFIToKEPUB (exact-match and percentage-fallback
  paths).
- kobo.go: Add libraryService field and SetLibraryService setter
  (mirrors KOReaderHandler pattern). Add convertKoboCFIToStandard
  helper that resolves EPUB+KEPUB paths, instantiates the converter,
  and returns the converted CFI + extracted context. The last-read-place
  branch in Markup now calls this helper for reflowable formats,
  skipping fixed-layout/comic archives (page-index only).
- router.go: Add LibraryService to router Config.
- sync.go: Wire LibraryService to KoboHandler via SetLibraryService.
- main.go: Pass libraryService through router config.

The conversion is purely additive — if no KEPUB file exists on disk
(e.g. side-loaded EPUB without kepubify conversion), the handler
gracefully skips conversion and stores the raw CFI as before.
2026-06-08 19:45:01 -04:00

322 lines
8.6 KiB
Go

package sync
import (
"fmt"
"log"
"golang.org/x/net/html"
)
type KEPUBCFIConverter struct {
epubConverter *CFIConverter
kepubConverter *CFIConverter
}
type KEPUBConversionResult struct {
CFI string
ExtractedContext string
Percentage float64
Precision string
}
func NewKEPUBCFIConverter(epubPath, kepubPath string) *KEPUBCFIConverter {
return &KEPUBCFIConverter{
epubConverter: NewCFIConverter(epubPath),
kepubConverter: NewCFIConverter(kepubPath),
}
}
func (k *KEPUBCFIConverter) ConvertKEPUBCFIToStandard(kepubCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
if !IsStandardEPUBCFI(kepubCFI) {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
if err != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubTextNode, kepubOffset, resolveErr := resolveCFIToNode(kepubDoc, steps)
if resolveErr != nil {
return k.kepubToStandardByPercentage(spineIndex, percentage)
}
var searchText string
if contextText != "" {
searchText = normalizeWhitespace(contextText)
} else if kepubTextNode != nil && kepubTextNode.Type == html.TextNode {
searchText = extractSurroundingText(kepubTextNode, kepubOffset, 80)
}
if searchText != "" {
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
if docErr == nil {
epubBody := findBody(epubDoc)
if epubBody != nil {
matchNode, matchOffset := findTextInNode(epubBody, searchText)
if matchNode != nil {
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
if buildErr == nil && cfi != "" {
return &KEPUBConversionResult{
CFI: cfi,
ExtractedContext: searchText,
Percentage: percentage,
Precision: "exact",
}, nil
}
}
}
}
}
result, _ := k.kepubToStandardByPercentage(spineIndex, percentage)
result.ExtractedContext = searchText
return result, nil
}
func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
if !IsStandardEPUBCFI(standardCFI) {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
spineIndex, steps, err := parseEPUBCFI(standardCFI)
if err != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubTextNode, epubOffset, resolveErr := resolveCFIToNode(epubDoc, steps)
if resolveErr != nil {
return k.standardToKEPUBByPercentage(spineIndex, percentage)
}
var searchText string
if contextText != "" {
searchText = normalizeWhitespace(contextText)
} else if epubTextNode != nil && epubTextNode.Type == html.TextNode {
searchText = extractSurroundingText(epubTextNode, epubOffset, 80)
}
if searchText != "" {
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr == nil {
kepubBody := findBody(kepubDoc)
if kepubBody != nil {
matchNode, matchOffset := findTextInNode(kepubBody, searchText)
if matchNode != nil {
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
if buildErr == nil && cfi != "" {
return &KEPUBConversionResult{
CFI: cfi,
ExtractedContext: searchText,
Percentage: percentage,
Precision: "exact",
}, nil
}
}
}
}
}
result, _ := k.standardToKEPUBByPercentage(spineIndex, percentage)
result.ExtractedContext = searchText
return result, nil
}
func (k *KEPUBCFIConverter) kepubToStandardByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
if percentage <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubBody := findBody(epubDoc)
if epubBody == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
totalChars := countTextChars(epubBody)
if totalChars <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
targetOffset := int(float64(totalChars) * percentage)
targetNode, foundOffset := findNodeAtCharOffset(epubBody, targetOffset)
if targetNode == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
charInNode := targetOffset - foundOffset
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
if err != nil || cfi == "" {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
return &KEPUBConversionResult{
CFI: cfi,
Percentage: percentage,
Precision: "percentage",
}, nil
}
func (k *KEPUBCFIConverter) standardToKEPUBByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
if percentage <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubBody := findBody(kepubDoc)
if kepubBody == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
totalChars := countTextChars(kepubBody)
if totalChars <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
targetOffset := int(float64(totalChars) * percentage)
targetNode, foundOffset := findNodeAtCharOffset(kepubBody, targetOffset)
if targetNode == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
charInNode := targetOffset - foundOffset
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
if err != nil || cfi == "" {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
return &KEPUBConversionResult{
CFI: cfi,
Percentage: percentage,
Precision: "percentage",
}, nil
}
func extractSurroundingText(textNode *html.Node, offset int, window int) string {
if textNode == nil || textNode.Type != html.TextNode {
return ""
}
block := findBlockParent(textNode)
if block == nil {
text := textNode.Data
runes := []rune(text)
start := offset - window
if start < 0 {
start = 0
}
end := offset + window
if end > len(runes) {
end = len(runes)
}
if start >= end {
return normalizeWhitespace(text)
}
return normalizeWhitespace(string(runes[start:end]))
}
segments := collectInlineText(block)
globalOffset := 0
for _, seg := range segments {
if seg.node == textNode {
globalOffset += offset
break
}
globalOffset += len(seg.runes)
}
var allRunes []rune
for _, seg := range segments {
allRunes = append(allRunes, seg.runes...)
}
start := globalOffset - window
if start < 0 {
start = 0
}
end := globalOffset + window
if end > len(allRunes) {
end = len(allRunes)
}
if start >= end {
return normalizeWhitespace(string(allRunes))
}
return normalizeWhitespace(string(allRunes[start:end]))
}
func (k *KEPUBCFIConverter) ComputeKEPUBPercentage(kepubCFI string) (float64, error) {
if !IsStandardEPUBCFI(kepubCFI) {
return -1, fmt.Errorf("not a standard epubcfi")
}
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
if err != nil {
return -1, err
}
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return -1, docErr
}
kepubBody := findBody(kepubDoc)
if kepubBody == nil {
return -1, fmt.Errorf("no body in kepub doc")
}
textNode, textOffset, resolveErr := resolveCFIToNode(kepubBody, steps)
if resolveErr != nil {
return -1, resolveErr
}
charOffset := countTextCharsBefore(textNode) + textOffset
total := 0
kepubSpine, spineErr := k.kepubConverter.loadSpine()
if spineErr != nil {
return -1, spineErr
}
for i := range kepubSpine.items {
doc, _, dErr := k.kepubConverter.getContentDoc(i + 1)
if dErr != nil {
continue
}
b := findBody(doc)
if b != nil {
total += countTextChars(b)
}
}
if total <= 0 {
return -1, fmt.Errorf("no text in kepub")
}
return float64(charOffset) / float64(total), nil
}
func init() {
_ = log.Printf
}