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 (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"context"
"fmt"
"log"
"net/http"
"time"
@@ -13,10 +15,15 @@ import (
)
type KOReaderHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor
progressSvc *wsync.ProgressService
db *database.Queries
connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor
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 {
@@ -27,6 +34,10 @@ func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc
}
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = svc
}
type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"`
@@ -46,11 +57,12 @@ type KOReaderBookProgress struct {
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
ContextText *string `json:"context_text,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
}
type KOReaderDeviceInfo struct {
@@ -99,11 +111,18 @@ type KOReaderNote struct {
}
type KOReaderSyncResponse struct {
SyncStatus string `json:"sync_status"`
BooksSynced int `json:"books_synced"`
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
Timestamp string `json:"timestamp"`
DeviceUpdated bool `json:"device_updated"`
SyncStatus string `json:"sync_status"`
BooksSynced int `json:"books_synced"`
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
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 {
@@ -124,13 +143,14 @@ type KOReaderMetadata struct {
}
type KOReaderProgressData struct {
Percentage float64 `json:"percentage"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
Chapter *int `json:"chapter,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
Percentage float64 `json:"percentage"`
Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"`
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
Chapter *int `json:"chapter,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
}
type KOReaderAnnotations struct {
@@ -187,17 +207,28 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
booksSynced := 0
conflicts := []KOReaderConflict{}
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue
}
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
if err == nil {
synced := err == nil
if synced {
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)
@@ -211,6 +242,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "accepted",
BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true,
@@ -220,6 +252,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusOK, KOReaderSyncResponse{
SyncStatus: "completed",
BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
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 {
booksEnqueued := 0
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue
}
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
if err == nil {
synced := err == nil
if synced {
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)
@@ -366,11 +410,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
})
}
return c.JSON(http.StatusAccepted, map[string]interface{}{
"sync_status": "checkpoint_enqueued",
"books_enqueued": booksEnqueued,
"message": "Sync will be processed in the background",
"timestamp": time.Now().Format(time.RFC3339),
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "checkpoint_enqueued",
BooksSynced: booksEnqueued,
BookResults: bookResults,
Timestamp: time.Now().Format(time.RFC3339),
})
}
@@ -385,6 +429,7 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
UserID: userID,
Percentage: book.Percentage,
Epubcfi: book.Epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter,
Character: book.Character,
Page: book.Page,
@@ -406,13 +451,59 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
}
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{
MediaItemID: mediaItemID,
UserID: userID,
Source: "koreader",
DeviceID: deviceID,
Percentage: &book.Percentage,
Epubcfi: book.Epubcfi,
Epubcfi: epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter,
CharacterOffset: book.Character,
CurrentPage: book.Page,
@@ -526,6 +617,9 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
if progress.Epubcfi.Valid {
progressData.Epubcfi = &progress.Epubcfi.String
}
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
}
if progress.Chapter.Valid {
progress := int(progress.Chapter.Int32)
progressData.Chapter = &progress
@@ -593,6 +687,40 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
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 {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes