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 (
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user