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
This commit is contained in:
+122
-14
@@ -3,11 +3,13 @@ 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"
|
||||
)
|
||||
@@ -240,6 +242,35 @@ func (h *KOReaderHandler) SyncProgress(c echo.Context) error {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -262,7 +293,7 @@ func (h *KOReaderHandler) updateProgressForBook(c echo.Context, userID pgtype.UU
|
||||
totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true}
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||
@@ -274,26 +305,103 @@ func (h *KOReaderHandler) updateProgressForBook(c echo.Context, userID pgtype.UU
|
||||
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 {
|
||||
// Broadcast progress update to all connected WebSocket clients
|
||||
deviceInfo := book.DeviceInfo
|
||||
if deviceInfo.DeviceModel == "" {
|
||||
deviceInfo.DeviceModel = "KOReader Device"
|
||||
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
|
||||
}
|
||||
|
||||
h.connManager.BroadcastProgressUpdate(
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
book.Percentage,
|
||||
wsync.SourceDevice{
|
||||
ID: uuid.UUID(userID.Bytes).String(),
|
||||
Name: deviceInfo.DeviceModel,
|
||||
Type: "koreader",
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user