From f6d98dd7cc2a3099aaec36c8850ae89a54134be7 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 8 Jun 2026 19:45:01 -0400 Subject: [PATCH] feat(sync): convert KEPUB CFI to standard and extract context_text on Kobo push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/main.go | 1 + internal/handlers/kobo.go | 70 ++++++++++++++++++++++++++-- internal/router/router.go | 3 +- internal/router/sync.go | 1 + internal/sync/kepub_cfi_converter.go | 29 +++++++----- 5 files changed, 89 insertions(+), 15 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 03379ae..ea041a3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -165,6 +165,7 @@ func main() { DeviceAuthMiddleware: deviceAuthMiddleware, JobsHandler: jobsHandler, LoginTracker: loginAttemptTracker, + LibraryService: libraryService, } // Register all routes and get ebook handler diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 8e0e10c..3f44442 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index 44a7176..8f25e62 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 diff --git a/internal/router/sync.go b/internal/router/sync.go index 9559b96..4731fa6 100644 --- a/internal/router/sync.go +++ b/internal/router/sync.go @@ -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)) diff --git a/internal/sync/kepub_cfi_converter.go b/internal/sync/kepub_cfi_converter.go index 4a2aad1..cb5d177 100644 --- a/internal/sync/kepub_cfi_converter.go +++ b/internal/sync/kepub_cfi_converter.go @@ -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) {