From 44f38803dcb594461a7e6257c85e4df2f4eaf19d Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 2 Jun 2026 19:44:47 -0400 Subject: [PATCH] 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 --- internal/handlers/koreader.go | 186 ++++++++++++++++++++++++++++------ 1 file changed, 157 insertions(+), 29 deletions(-) diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index b6b8f47..438df42 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -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