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:
+157
-29
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user