feat(koreader): bidirectional CFI conversion in sync pipeline

Forward (push): When KOReader pushes a CREngine XPointer, convert it
to standard epubcfi before storing. Uses CFIConverter.ConvertCREToStandard
with context_text for text search fallback.

Reverse (pull): When KOReader pulls progress, convert stored standard
epubcfi back to CREngine XPointer via CFIConverter.ConvertStandardToCRE.
Returns as koreader_xpointer field in GetMetadata response.

Other changes:
- updateProgressForBook: pass context_text to SaveProgress
- enqueueProgressForBook: pass context_text to queue
- KOReaderProgressData: add KoreaderXPointer field
- New convertCFIToXPointer helper method
- Wire libraryService in main.go for EPUB path resolution
This commit is contained in:
2026-06-02 19:44:47 -04:00
parent b2b1804aa3
commit 44f38803dc
+157 -29
View File
@@ -3,7 +3,9 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"context"
"fmt" "fmt"
"log"
"net/http" "net/http"
"time" "time"
@@ -13,10 +15,15 @@ import (
) )
type KOReaderHandler struct { type KOReaderHandler struct {
db *database.Queries db *database.Queries
connManager *wsync.ConnectionManager connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor queue *wsync.SyncQueueProcessor
progressSvc *wsync.ProgressService progressSvc *wsync.ProgressService
libraryService LibraryPathResolver
}
type LibraryPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
} }
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler { func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
@@ -27,6 +34,10 @@ func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc h.progressSvc = svc
} }
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = svc
}
type KOReaderProgressRequest struct { type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"` LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"` Books []KOReaderBookProgress `json:"books" validate:"required"`
@@ -46,11 +57,12 @@ type KOReaderBookProgress struct {
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"` Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"` Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"` Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"` Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"` Epubcfi *string `json:"epubcfi,omitempty"`
Page *int `json:"page,omitempty"` ContextText *string `json:"context_text,omitempty"`
TotalPages *int `json:"total_pages,omitempty"` Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
} }
type KOReaderDeviceInfo struct { type KOReaderDeviceInfo struct {
@@ -99,11 +111,18 @@ type KOReaderNote struct {
} }
type KOReaderSyncResponse struct { type KOReaderSyncResponse struct {
SyncStatus string `json:"sync_status"` SyncStatus string `json:"sync_status"`
BooksSynced int `json:"books_synced"` BooksSynced int `json:"books_synced"`
Conflicts []KOReaderConflict `json:"conflicts,omitempty"` BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
Timestamp string `json:"timestamp"` Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
DeviceUpdated bool `json:"device_updated"` Timestamp string `json:"timestamp"`
DeviceUpdated bool `json:"device_updated"`
}
type KOReaderBookSyncResult struct {
SHA256 string `json:"sha256"`
BookUUID string `json:"book_uuid"`
Synced bool `json:"synced"`
} }
type KOReaderConflict struct { type KOReaderConflict struct {
@@ -124,13 +143,14 @@ type KOReaderMetadata struct {
} }
type KOReaderProgressData struct { type KOReaderProgressData struct {
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
Character *int64 `json:"character,omitempty"` Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"` Epubcfi *string `json:"epubcfi,omitempty"`
Chapter *int `json:"chapter,omitempty"` KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"` Chapter *int `json:"chapter,omitempty"`
Page *int `json:"page,omitempty"` ChapterProgress *float64 `json:"chapter_progress,omitempty"`
TotalPages *int `json:"total_pages,omitempty"` Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
} }
type KOReaderAnnotations struct { type KOReaderAnnotations struct {
@@ -187,17 +207,28 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
booksSynced := 0 booksSynced := 0
conflicts := []KOReaderConflict{} conflicts := []KOReaderConflict{}
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books { for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book) mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid { if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue continue
} }
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book) err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
if err == nil { synced := err == nil
if synced {
booksSynced++ booksSynced++
} }
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
} }
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID) _, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
@@ -211,6 +242,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{ return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "accepted", SyncStatus: "accepted",
BooksSynced: booksSynced, BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts, Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339), Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true, DeviceUpdated: true,
@@ -220,6 +252,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusOK, KOReaderSyncResponse{ return c.JSON(http.StatusOK, KOReaderSyncResponse{
SyncStatus: "completed", SyncStatus: "completed",
BooksSynced: booksSynced, BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts, Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339), Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true, DeviceUpdated: true,
@@ -346,17 +379,28 @@ func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error { func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
booksEnqueued := 0 booksEnqueued := 0
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books { for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book) mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
if !mediaItemID.Valid { if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue continue
} }
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book) err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
if err == nil { synced := err == nil
if synced {
booksEnqueued++ booksEnqueued++
} }
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
} }
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID) _, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
@@ -366,11 +410,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
}) })
} }
return c.JSON(http.StatusAccepted, map[string]interface{}{ return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
"sync_status": "checkpoint_enqueued", SyncStatus: "checkpoint_enqueued",
"books_enqueued": booksEnqueued, BooksSynced: booksEnqueued,
"message": "Sync will be processed in the background", BookResults: bookResults,
"timestamp": time.Now().Format(time.RFC3339), Timestamp: time.Now().Format(time.RFC3339),
}) })
} }
@@ -385,6 +429,7 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
UserID: userID, UserID: userID,
Percentage: book.Percentage, Percentage: book.Percentage,
Epubcfi: book.Epubcfi, Epubcfi: book.Epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter, Chapter: book.Chapter,
Character: book.Character, Character: book.Character,
Page: book.Page, Page: book.Page,
@@ -406,13 +451,59 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
} }
if h.progressSvc != nil { if h.progressSvc != nil {
epubcfi := book.Epubcfi
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
} else if h.libraryService == nil {
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
} else {
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if resolveErr != nil {
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
} else if epubPath == "" {
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
} else {
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
converter := wsync.NewCFIConverter(epubPath)
pct := 0.0
if book.Percentage >= 0 {
pct = book.Percentage
}
contextText := ""
if book.ContextText != nil {
contextText = *book.ContextText
}
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
if convErr != nil {
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
} else if result != nil {
if result.EPUBCFI != "" {
convertedCFI := result.EPUBCFI
epubcfi = &convertedCFI
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
} else if result.Href != "" {
convertedHref := result.Href
epubcfi = &convertedHref
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
} else {
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
}
}
}
}
}
saveReq := wsync.SaveProgressRequest{ saveReq := wsync.SaveProgressRequest{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
UserID: userID, UserID: userID,
Source: "koreader", Source: "koreader",
DeviceID: deviceID, DeviceID: deviceID,
Percentage: &book.Percentage, Percentage: &book.Percentage,
Epubcfi: book.Epubcfi, Epubcfi: epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter, Chapter: book.Chapter,
CharacterOffset: book.Character, CharacterOffset: book.Character,
CurrentPage: book.Page, CurrentPage: book.Page,
@@ -526,6 +617,9 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
if progress.Epubcfi.Valid { if progress.Epubcfi.Valid {
progressData.Epubcfi = &progress.Epubcfi.String progressData.Epubcfi = &progress.Epubcfi.String
} }
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
}
if progress.Chapter.Valid { if progress.Chapter.Valid {
progress := int(progress.Chapter.Int32) progress := int(progress.Chapter.Int32)
progressData.Chapter = &progress progressData.Chapter = &progress
@@ -593,6 +687,40 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
return c.JSON(http.StatusOK, metadata) return c.JSON(http.StatusOK, metadata)
} }
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
if h.libraryService == nil {
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
return
}
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
return
}
if epubPath == "" {
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
return
}
converter := wsync.NewCFIConverter(epubPath)
contextText := ""
if progress.ContextText.Valid {
contextText = progress.ContextText.String
}
pct := progress.Percentage.Float64
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
if err != nil {
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
return
}
if result != nil && result.XPointer != "" {
progressData.KoreaderXPointer = &result.XPointer
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
}
}
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
device := c.Get("device").(database.Devices) device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes userID := device.UserID.Bytes