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.
322 lines
8.6 KiB
Go
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
|
|
}
|