Files
bookhoard/internal/handlers/koreader.go
T
john-okeefe 2d2d643873 Add sync conflict detection and resolution system
Implement conflict detection for concurrent reading progress updates from different devices. Adds conflict management endpoints for listing, viewing, and resolving conflicts.

- Add ConflictHandler with CRUD endpoints for conflict management
- Implement automatic conflict detection in KOReader progress updates
- Add WebSocket broadcast for real-time conflict notifications
- Add database query for listing user conflicts by status
- Add integration tests and Bruno API test collection
2026-01-31 11:45:52 -05:00

695 lines
20 KiB
Go

package handlers
import (
"bookmann/internal/database"
wsync "bookmann/internal/sync"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type KOReaderHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
}
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KOReaderHandler {
return &KOReaderHandler{db: db, connManager: connManager}
}
type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"`
SyncMode string `json:"sync_mode,omitempty"`
}
type KOReaderBookProgress struct {
UUID string `json:"uuid,omitempty"`
Title string `json:"title,omitempty"`
Authors []string `json:"authors,omitempty"`
Progress float64 `json:"progress"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read,omitempty"`
FilePath string `json:"file_path,omitempty"`
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
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"`
}
type KOReaderDeviceInfo struct {
KOReaderVersion string `json:"koreader_version,omitempty"`
DeviceModel string `json:"device_model,omitempty"`
}
type KOReaderBookmark struct {
Chapter int `json:"chapter,omitempty"`
Datetime string `json:"datetime,omitempty"`
Notes string `json:"notes,omitempty"`
Pos0 string `json:"pos0,omitempty"`
Pos1 string `json:"pos1,omitempty"`
Page int `json:"page,omitempty"`
Text string `json:"text,omitempty"`
Type string `json:"type,omitempty"`
Percentage *float64 `json:"percentage,omitempty"`
}
type KOReaderHighlight struct {
Chapter int `json:"chapter,omitempty"`
Datetime string `json:"datetime,omitempty"`
Notes string `json:"notes,omitempty"`
Pos0 string `json:"pos0,omitempty"`
Pos1 string `json:"pos1,omitempty"`
Page int `json:"page,omitempty"`
Text string `json:"text,omitempty"`
Type string `json:"type,omitempty"`
Color string `json:"color,omitempty"`
Percentage *float64 `json:"percentage,omitempty"`
}
type KOReaderNote struct {
Chapter int `json:"chapter,omitempty"`
Datetime string `json:"datetime,omitempty"`
Notes string `json:"notes,omitempty"`
Pos0 string `json:"pos0,omitempty"`
Pos1 string `json:"pos1,omitempty"`
Page int `json:"page,omitempty"`
Text string `json:"text,omitempty"`
Type string `json:"type,omitempty"`
Percentage *float64 `json:"percentage,omitempty"`
}
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"`
}
type KOReaderConflict struct {
BookUUID string `json:"book_uuid"`
ConflictType string `json:"conflict_type"`
DeviceProgress float64 `json:"device_progress"`
ServerProgress float64 `json:"server_progress"`
Resolution string `json:"resolution"`
}
type KOReaderMetadata struct {
UUID string `json:"uuid"`
Title string `json:"title"`
Authors []string `json:"authors"`
Progress KOReaderProgressData `json:"progress"`
Annotations KOReaderAnnotations `json:"annotations"`
LastSync string `json:"last_sync"`
}
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"`
}
type KOReaderAnnotations struct {
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
}
type KOReaderLibraryResponse struct {
LibrarySync []KOReaderLibraryBook `json:"library_sync"`
TotalBooks int `json:"total_books"`
LastSync string `json:"last_sync"`
}
type KOReaderLibraryBook struct {
UUID string `json:"uuid"`
Title string `json:"title"`
Author string `json:"author"`
ContentType string `json:"content_type"`
PercentRead float64 `json:"percent_read"`
PagesRemaining *int `json:"pages_remaining,omitempty"`
BookmarkCount int `json:"bookmark_count"`
LastModified string `json:"last_modified"`
}
func (h *KOReaderHandler) SyncProgress(c echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
var req KOReaderProgressRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request format",
"details": err.Error(),
})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
}
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
booksSynced := 0
conflicts := []KOReaderConflict{}
for _, book := range req.Books {
var mediaUUID uuid.UUID
var err error
if book.UUID != "" {
mediaUUID, err = uuid.Parse(book.UUID)
if err != nil {
continue
}
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err == nil {
err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book)
if err == nil {
booksSynced++
}
}
} else if book.FilePath != "" {
mediaItem, err := h.db.GetMediaItemByFilePath(c.Request().Context(), book.FilePath)
if err == nil {
err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book)
if err == nil {
booksSynced++
}
}
} else if book.Title != "" {
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: 100,
Offset: 0,
})
if err == nil {
for _, mi := range mediaItems {
if mi.Title == book.Title && (book.Authors == nil || mi.Author.String == book.Authors[0]) {
err = h.updateProgressForBook(c, pgUserID, mi.ID, book)
if err == nil {
booksSynced++
}
break
}
}
}
}
}
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to update device timestamp",
})
}
if req.SyncMode == "immediate" {
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "accepted",
BooksSynced: booksSynced,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true,
})
}
return c.JSON(http.StatusOK, KOReaderSyncResponse{
SyncStatus: "completed",
BooksSynced: booksSynced,
Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true,
})
}
func (h *KOReaderHandler) updateProgressForBook(c echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
ctx := c.Request().Context()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && err != pgx.ErrNoRows {
return err
}
hasExistingProgress := err != pgx.ErrNoRows
conflictDetected := false
if hasExistingProgress && existingProgress.LastSyncSource.Valid {
if existingProgress.LastSyncSource.String != "koreader" && existingProgress.LastSyncTimestamp.Valid {
timeDiff := time.Since(existingProgress.LastSyncTimestamp.Time)
if timeDiff < 5*time.Minute {
percentageDiff := book.Percentage - existingProgress.Percentage.Float64
if percentageDiff < 0 {
percentageDiff = -percentageDiff
}
if percentageDiff > 0.01 {
conflictDetected = true
}
}
}
}
var epubcfi pgtype.Text
var chapter pgtype.Int4
var characterOffset pgtype.Int8
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if book.Epubcfi != nil {
epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true}
}
if book.Chapter != nil {
chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true}
}
if book.Character != nil {
characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true}
}
if book.Page != nil {
currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true}
}
if book.TotalPages != nil {
totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
if err != nil {
return err
}
if conflictDetected {
koreaderData := map[string]interface{}{
"source": "koreader",
"timestamp": time.Now(),
"data": map[string]interface{}{
"percentage": book.Percentage,
},
}
if book.Epubcfi != nil {
koreaderData["data"].(map[string]interface{})["epubcfi"] = *book.Epubcfi
}
if book.Chapter != nil {
koreaderData["data"].(map[string]interface{})["chapter"] = *book.Chapter
}
if book.Character != nil {
koreaderData["data"].(map[string]interface{})["character"] = *book.Character
}
if book.Page != nil {
koreaderData["data"].(map[string]interface{})["page"] = *book.Page
}
if book.TotalPages != nil {
koreaderData["data"].(map[string]interface{})["total_pages"] = *book.TotalPages
}
existingData := map[string]interface{}{
"source": existingProgress.LastSyncSource.String,
"timestamp": existingProgress.LastSyncTimestamp.Time,
"data": map[string]interface{}{
"percentage": existingProgress.Percentage.Float64,
},
}
if existingProgress.Epubcfi.Valid {
existingData["data"].(map[string]interface{})["epubcfi"] = existingProgress.Epubcfi.String
}
if existingProgress.Chapter.Valid {
existingData["data"].(map[string]interface{})["chapter"] = existingProgress.Chapter.Int32
}
if existingProgress.CharacterOffset.Valid {
existingData["data"].(map[string]interface{})["character"] = existingProgress.CharacterOffset.Int64
}
if existingProgress.CurrentPage.Valid {
existingData["data"].(map[string]interface{})["page"] = existingProgress.CurrentPage.Int32
}
if existingProgress.TotalPages.Valid {
existingData["data"].(map[string]interface{})["total_pages"] = existingProgress.TotalPages.Int32
}
conflictData := map[string]interface{}{
"koreader": koreaderData,
"existing": existingData,
}
conflictDataJSON, _ := json.Marshal(conflictData)
_, err := h.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
MediaItemID: mediaItemID,
UserID: userID,
ConflictType: "progress",
ConflictData: conflictDataJSON,
})
if err == nil {
h.connManager.BroadcastConflictNotification(
mediaItemID.Bytes,
"detection",
"",
)
}
}
deviceInfo := book.DeviceInfo
if deviceInfo.DeviceModel == "" {
deviceInfo.DeviceModel = "KOReader Device"
}
h.connManager.BroadcastProgressUpdate(
uuid.UUID(mediaItemID.Bytes),
book.Percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID.Bytes).String(),
Name: deviceInfo.DeviceModel,
Type: "koreader",
},
)
_, err = h.db.UpdateDeviceLastSync(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: false})
return err
}
func (h *KOReaderHandler) GetMetadata(c echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
bookUUID, err := uuid.Parse(c.Param("uuid"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid book UUID",
})
}
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "book not found",
})
}
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
if err != nil {
return c.JSON(http.StatusOK, map[string]interface{}{
"uuid": bookUUID,
"title": mediaItem.Title,
"author": mediaItem.Author,
"progress": nil,
"annotations": map[string][]interface{}{},
})
}
progressData := KOReaderProgressData{
Percentage: progress.Percentage.Float64,
}
if progress.Epubcfi.Valid {
progressData.Epubcfi = &progress.Epubcfi.String
}
if progress.Chapter.Valid {
ch := int(progress.Chapter.Int32)
progressData.Chapter = &ch
}
if progress.ChapterProgress.Valid {
cp := progress.ChapterProgress.Float64
progressData.ChapterProgress = &cp
}
if progress.CharacterOffset.Valid {
co := int64(progress.CharacterOffset.Int64)
progressData.Character = &co
}
if progress.CurrentPage.Valid {
cp := int(progress.CurrentPage.Int32)
progressData.Page = &cp
}
if progress.TotalPages.Valid {
tp := int(progress.TotalPages.Int32)
progressData.TotalPages = &tp
}
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
annotationsResponse := KOReaderAnnotations{
Highlights: []KOReaderHighlight{},
Notes: []KOReaderNote{},
Bookmarks: []KOReaderBookmark{},
}
for _, ann := range annotations {
if ann.AnnotationType == "highlight" {
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
Text: ann.SelectionText,
Pos0: ann.StartPosition.String,
Pos1: ann.EndPosition.String,
Color: ann.Color.String,
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
})
} else if ann.AnnotationType == "note" {
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
Text: ann.SelectionText,
Pos0: ann.StartPosition.String,
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
})
}
}
lastSync := "never"
if progress.LastSyncTimestamp.Valid {
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
}
metadata := KOReaderMetadata{
UUID: bookUUID.String(),
Title: mediaItem.Title,
Authors: []string{mediaItem.Author.String},
Progress: progressData,
Annotations: annotationsResponse,
LastSync: lastSync,
}
return c.JSON(http.StatusOK, metadata)
}
func (h *KOReaderHandler) GetLibrary(c echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
mediaItems, err := h.db.GetUserMediaItemsForSync(c.Request().Context(), pgUserID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "failed to fetch library",
})
}
libraryBooks := []KOReaderLibraryBook{}
for _, item := range mediaItems {
pgItemUUID := pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: pgItemUUID,
UserID: pgUserID,
})
percentRead := 0.0
var pagesRemaining *int
bookmarkCount := 0
lastModified := time.Now().Format(time.RFC3339)
if err == nil {
percentRead = progress.Percentage.Float64
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
pagesRemaining = &remaining
}
if progress.LastReadAt.Valid {
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
}
}
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
MediaItemID: pgItemUUID,
UserID: pgUserID,
})
bookmarkCount = len(annotations)
libraryBooks = append(libraryBooks, KOReaderLibraryBook{
UUID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title,
Author: item.Author.String,
ContentType: "6",
PercentRead: percentRead * 100,
PagesRemaining: pagesRemaining,
BookmarkCount: bookmarkCount,
LastModified: lastModified,
})
}
return c.JSON(http.StatusOK, KOReaderLibraryResponse{
LibrarySync: libraryBooks,
TotalBooks: len(libraryBooks),
LastSync: time.Now().Format(time.RFC3339),
})
}
func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
var req struct {
BookUUID string `json:"book_uuid" validate:"required"`
Bookmarks []KOReaderBookmark `json:"bookmarks"`
Notes []KOReaderNote `json:"notes"`
Highlights []KOReaderHighlight `json:"highlights"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request format",
})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
}
bookUUID, err := uuid.Parse(req.BookUUID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid book UUID",
})
}
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
bookmarksSynced := 0
notesSynced := 0
highlightsSynced := 0
for _, bookmark := range req.Bookmarks {
position := ""
if bookmark.Pos0 != "" {
position = bookmark.Pos0
} else if bookmark.Page > 0 {
position = fmt.Sprintf("page:%d", bookmark.Page)
}
_, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
Content: bookmark.Text,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
bookmarksSynced++
}
}
for _, note := range req.Notes {
position := ""
if note.Pos0 != "" {
position = note.Pos0
} else if note.Page > 0 {
position = fmt.Sprintf("page:%d", note.Page)
}
_, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
Content: note.Notes,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
notesSynced++
}
}
for _, highlight := range req.Highlights {
startPos := highlight.Pos0
endPos := highlight.Pos1
if startPos == "" && highlight.Page > 0 {
startPos = fmt.Sprintf("page:%d", highlight.Page)
endPos = startPos
}
color := "#ffff00"
if highlight.Color != "" {
color = highlight.Color
}
_, err := h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
Color: pgtype.Text{String: color, Valid: true},
})
if err == nil {
highlightsSynced++
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"sync_status": "completed",
"bookmarks_synced": bookmarksSynced,
"notes_synced": notesSynced,
"highlights_synced": highlightsSynced,
"total_synced": bookmarksSynced + notesSynced + highlightsSynced,
"timestamp": time.Now().Format(time.RFC3339),
})
}