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.
This commit is contained in:
2026-06-08 19:45:01 -04:00
parent e584200369
commit f6d98dd7cc
5 changed files with 89 additions and 15 deletions
+67 -3
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"fmt"
"log"
"net/http"
"regexp"
"strings"
@@ -15,9 +16,10 @@ import (
)
type KoboHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
libraryService LibraryPathResolver
}
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
@@ -28,6 +30,10 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc
}
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = svc
}
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
// Enhanced Kobo Sync - ContentId Mapping Logic
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
@@ -495,6 +501,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
chapter := bookmarkSync.Chapter
chapterProgress := 0.5
var convertedCFI *string
var contextText *string
if epubcfi != "" && h.libraryService != nil {
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
if mErr == nil {
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
}
}
}
if convertedCFI != nil {
epubcfi = *convertedCFI
}
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
@@ -502,6 +525,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Epubcfi: &epubcfi,
ContextText: contextText,
Chapter: &chapter,
ChapterProgress: &chapterProgress,
DeviceType: "kobo",
@@ -778,3 +802,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
HighlightsSent: highlightsSent,
})
}
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
return nil, nil
}
if epubPath == "" {
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
return nil, nil
}
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
FormatType: "kepub",
})
if err != nil || !kepubFormat.FilePath.Valid {
return nil, nil
}
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
if err != nil {
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
return nil, nil
}
var cfi *string
if result.CFI != "" {
cfi = &result.CFI
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
}
var ctx *string
if result.ExtractedContext != "" {
ctx = &result.ExtractedContext
}
return cfi, ctx
}
+2 -1
View File
@@ -66,8 +66,9 @@ type Config struct {
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
JobsHandler *handlers.JobsHandler
SidecarHandler *handlers.SidecarHandler
SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService
}
// createJWTMiddleware creates a JWT middleware with proper user context setup
+1
View File
@@ -33,6 +33,7 @@ func registerSyncRoutes(cfg *Config) {
// API clients can use Authorization header: Authorization: Bearer {token}
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
koboHandler.SetProgressService(cfg.ProgressService)
koboHandler.SetLibraryService(cfg.LibraryService)
koboSync := e.Group("/api/sync/kobo/:token")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
+18 -11
View File
@@ -13,9 +13,10 @@ type KEPUBCFIConverter struct {
}
type KEPUBConversionResult struct {
CFI string
Percentage float64
Precision string
CFI string
ExtractedContext string
Percentage float64
Precision string
}
func NewKEPUBCFIConverter(epubPath, kepubPath string) *KEPUBCFIConverter {
@@ -62,9 +63,10 @@ func (k *KEPUBCFIConverter) ConvertKEPUBCFIToStandard(kepubCFI string, percentag
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
if buildErr == nil && cfi != "" {
return &KEPUBConversionResult{
CFI: cfi,
Percentage: percentage,
Precision: "exact",
CFI: cfi,
ExtractedContext: searchText,
Percentage: percentage,
Precision: "exact",
}, nil
}
}
@@ -72,7 +74,9 @@ func (k *KEPUBCFIConverter) ConvertKEPUBCFIToStandard(kepubCFI string, percentag
}
}
return k.kepubToStandardByPercentage(spineIndex, percentage)
result, _ := k.kepubToStandardByPercentage(spineIndex, percentage)
result.ExtractedContext = searchText
return result, nil
}
func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
@@ -112,9 +116,10 @@ func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percen
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
if buildErr == nil && cfi != "" {
return &KEPUBConversionResult{
CFI: cfi,
Percentage: percentage,
Precision: "exact",
CFI: cfi,
ExtractedContext: searchText,
Percentage: percentage,
Precision: "exact",
}, nil
}
}
@@ -122,7 +127,9 @@ func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percen
}
}
return k.standardToKEPUBByPercentage(spineIndex, percentage)
result, _ := k.standardToKEPUBByPercentage(spineIndex, percentage)
result.ExtractedContext = searchText
return result, nil
}
func (k *KEPUBCFIConverter) kepubToStandardByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {