Files
john-okeefe 75b33fdae6 feat(sync): wire annotation sync into all device and web handlers
Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.

INGEST (device → server):

KOReader (koreader.go):
  - Add processBookAnnotations helper that processes inline highlights,
    notes, and bookmarks from every progress push (immediate + checkpoint)
  - Highlights get CRE→CFI position conversion before SaveHighlight
  - KOReader 'notes' (text + notes) stored as highlights with NoteText
    to ensure correct round-trip classification
  - Bookmarks routed through SaveBookmark with device sync data
  - Called from both updateProgressForBook and handleCheckpointSync

Kobo (kobo.go):
  - Markup handler: annotations and bookmarks route through
    AnnotationService (SaveHighlight/SaveBookmark)
  - Bookmark handler: same routing with device sync data
  - SyncFromServer handler: same routing
  - All handlers fall back to direct DB calls when annotationSvc == nil

Web reader (media.go):
  - CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
  - CreateMediaNote → SaveNote (Source="web")
  - DeleteMediaHighlight → TombstoneHighlightByID
  - DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
  - All fall back to old behavior when annotationSvc == nil

SERVE (server → device):

KOReader GetMetadata (koreader.go):
  - Query and serve bookmarks from media_bookmarks table (was missing)
  - Serve deleted_highlights and deleted_bookmarks arrays containing
    device_sync_data + dedup_key for client-side deletion
  - Highlights/notes already served with reverse CFI conversion

Kobo Markup handler (kobo.go):
  - Track processed books during sync
  - Query tombstones per book, extract bookmark_id from device_sync_data
  - Return DeletedAnnotations array in KoboSyncStatus response

Conflict resolution (conflicts.go):
  - Enable annotation conflict types in ResolveConflict handler
  - Add applyAnnotationResolution dispatching to:
    applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
  - Each looks up by dedup_key and applies winner's fields
  - Allow manual override of auto_resolved conflicts
    (changed check from != "unresolved" to == "user_resolved")

Infrastructure:
  - AnnotationService field + SetAnnotationService in router Config
  - Inject AnnotationService into KOReader, Kobo, Media handlers
  - Start tombstone purger goroutine in main.go (24h interval)
  - Test helpers: construct AnnotationService in test setup
2026-07-29 14:49:19 -04:00

927 lines
28 KiB
Go

package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type ConflictHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
}
func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManager) *ConflictHandler {
return &ConflictHandler{
db: db,
connManager: connManager,
}
}
type ConflictResolutionRequest struct {
Winner string `json:"winner" validate:"required"`
ManualData map[string]interface{} `json:"manual_data"`
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
Reason string `json:"reason"`
}
type ConflictSourceData struct {
Source string `json:"source"`
Timestamp time.Time `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
type ConflictDetailResponse struct {
ID string `json:"id"`
MediaItemID string `json:"media_item_id"`
MediaItemTitle string `json:"media_item_title"`
ConflictType string `json:"conflict_type"`
ConflictData map[string]ConflictSourceData `json:"conflict_data"`
ResolutionStatus string `json:"resolution_status"`
ResolutionData map[string]interface{} `json:"resolution_data,omitempty"`
ResolvedBy string `json:"resolved_by,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ConflictListResponse struct {
Conflicts []ConflictDetailResponse `json:"conflicts"`
Total int `json:"total"`
Unresolved int `json:"unresolved"`
}
type ConflictResolveResponse struct {
ConflictResolved bool `json:"conflict_resolved"`
AppliedTo map[string]bool `json:"applied_to"`
DevicesSynced []string `json:"devices_synced"`
}
func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailResponse, int, int, error) {
user := c.Get("user").(database.Users)
status := c.QueryParam("status")
if status == "" {
status = "unresolved"
}
ctx := context.Background()
var conflicts interface{}
var err error
if status == "all" {
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
} else {
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, 0, err
}
response := make([]ConflictDetailResponse, 0, len(conflicts.([]database.ListSyncConflictsByUserRow)))
unresolvedCount := 0
for _, conflict := range conflicts.([]database.ListSyncConflictsByUserRow) {
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
continue
}
detail := ConflictDetailResponse{
ID: uuid.UUID(conflict.ID.Bytes).String(),
MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(),
MediaItemTitle: conflict.Title,
ConflictType: conflict.ConflictType,
ConflictData: conflictData,
ResolutionStatus: conflict.ResolutionStatus.String,
CreatedAt: conflict.CreatedAt.Time,
}
if conflict.ResolvedBy.Valid {
detail.ResolvedBy = uuid.UUID(conflict.ResolvedBy.Bytes).String()
}
if conflict.ResolvedAt.Valid {
detail.ResolvedAt = &conflict.ResolvedAt.Time
}
if conflict.ResolutionData != nil {
if err := json.Unmarshal(conflict.ResolutionData, &detail.ResolutionData); err == nil {
}
}
response = append(response, detail)
if conflict.ResolutionStatus.String == "unresolved" {
unresolvedCount++
}
}
return response, len(response), unresolvedCount, nil
}
func (h *ConflictHandler) ListConflicts(c *echo.Context) error {
conflicts, total, unresolved, err := h.GetConflictsData(c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
}
return c.JSON(http.StatusOK, ConflictListResponse{
Conflicts: conflicts,
Total: total,
Unresolved: unresolved,
})
}
func (h *ConflictHandler) GetConflict(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflictID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid conflict ID")
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
}
if conflict.UserID.Bytes != user.ID.Bytes {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
mediaItem, err := h.db.GetMediaItem(context.Background(), conflict.MediaItemID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
}
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse conflict data")
}
detail := ConflictDetailResponse{
ID: uuid.UUID(conflict.ID.Bytes).String(),
MediaItemID: uuid.UUID(conflict.MediaItemID.Bytes).String(),
MediaItemTitle: mediaItem.Title,
ConflictType: conflict.ConflictType,
ConflictData: conflictData,
ResolutionStatus: conflict.ResolutionStatus.String,
CreatedAt: conflict.CreatedAt.Time,
}
if conflict.ResolvedBy.Valid {
detail.ResolvedBy = uuid.UUID(conflict.ResolvedBy.Bytes).String()
}
if conflict.ResolvedAt.Valid {
detail.ResolvedAt = &conflict.ResolvedAt.Time
}
if conflict.ResolutionData != nil {
if err := json.Unmarshal(conflict.ResolutionData, &detail.ResolutionData); err == nil {
}
}
return c.JSON(http.StatusOK, detail)
}
func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflictID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid conflict ID")
}
var req ConflictResolutionRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if req.Winner == "manual" && req.ManualData == nil {
return echo.NewHTTPError(http.StatusBadRequest, "manual_data required when winner is manual")
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
}
if conflict.UserID.Bytes != user.ID.Bytes {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if conflict.ResolutionStatus.String == "user_resolved" {
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
}
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse conflict data")
}
winnerData := map[string]interface{}{}
winnerSource := req.Winner
if req.Winner == "manual" {
winnerData = req.ManualData
winnerSource = "manual"
} else {
source, ok := conflictData[req.Winner]
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "invalid winner source")
}
winnerData = source.Data
}
appliedTo := map[string]bool{
"progress": false,
"annotations": false,
}
if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
appliedTo["progress"] = true
}
}
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
appliedTo["annotations"] = true
}
}
resolutionData := map[string]interface{}{
"winner": req.Winner,
"applied_to": appliedTo,
"reason": req.Reason,
"resolved_at": time.Now(),
}
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
ResolvedBy: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to resolve conflict")
}
devicesSynced := h.notifyDevicesOfResolution(conflict.MediaItemID, winnerData)
response := ConflictResolveResponse{
ConflictResolved: true,
AppliedTo: appliedTo,
DevicesSynced: devicesSynced,
}
return c.JSON(http.StatusOK, response)
}
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
percentage := 0.0
if p, ok := data["percentage"].(float64); ok {
percentage = p
}
var epubcfi pgtype.Text
if e, ok := data["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: e, Valid: true}
}
var chapter pgtype.Int4
if c, ok := data["chapter"].(float64); ok {
chapter = pgtype.Int4{Int32: int32(c), Valid: true}
}
var characterOffset pgtype.Int8
if c, ok := data["character"].(float64); ok {
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
}
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
}
if p, ok := data["total_pages"].(float64); ok {
totalPages = pgtype.Int4{Int32: int32(p), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
return err
}
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
ctx := context.Background()
dedupKey, _ := winnerData["dedup_key"].(string)
if dedupKey == "" {
return errors.New("missing dedup_key in winner data")
}
switch conflictType {
case "annotation_highlight":
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
case "annotation_bookmark":
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
case "annotation_note":
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
default:
return errors.New("unknown annotation conflict type")
}
}
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaHighlightForSyncParams{
ID: existing.ID,
SelectionText: existing.SelectionText,
StartPosition: existing.StartPosition,
EndPosition: existing.EndPosition,
Color: existing.Color,
NoteText: existing.NoteText,
PercentageStart: existing.PercentageStart,
PercentageEnd: existing.PercentageEnd,
EpubcfiStart: existing.EpubcfiStart,
EpubcfiEnd: existing.EpubcfiEnd,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["selection_text"].(string); ok {
params.SelectionText = v
}
if v, ok := data["color"].(string); ok {
params.Color = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["note_text"].(string); ok {
params.NoteText = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["start_position"].(string); ok {
params.StartPosition = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["end_position"].(string); ok {
params.EndPosition = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
return err
}
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: existing.PageNumber,
ChapterNumber: existing.ChapterNumber,
CfiPosition: existing.CfiPosition,
Title: existing.Title,
Position: existing.Position,
Notes: existing.Notes,
PercentageLocation: existing.PercentageLocation,
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["title"].(string); ok {
params.Title = v
}
if v, ok := data["notes"].(string); ok {
params.Notes = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
return err
}
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaNoteForSyncParams{
ID: existing.ID,
Content: existing.Content,
Position: existing.Position,
PercentageLocation: existing.PercentageLocation,
CharacterStart: existing.CharacterStart,
CharacterEnd: existing.CharacterEnd,
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
ParagraphReference: existing.ParagraphReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["content"].(string); ok {
params.Content = v
}
if v, ok := data["position"].(string); ok {
params.Position = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
return err
}
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
if err != nil {
return []string{}
}
synced := []string{}
for _, device := range devices {
if device.SyncEnabled.Bool {
synced = append(synced, uuid.UUID(device.ID.Bytes).String())
}
}
return synced
}
func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflictID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid conflict ID")
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
}
if conflict.UserID.Bytes != user.ID.Bytes {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete conflict")
}
return c.NoContent(http.StatusNoContent)
}
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
}
deleted := 0
for _, conflict := range conflicts {
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil {
deleted++
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"deleted": deleted,
})
}
type BulkResolveRequest struct {
ConflictIDs []string `json:"conflict_ids" validate:"required"`
Strategy string `json:"strategy" validate:"required,oneof=most_recent highest_progress manual"`
WinningSource string `json:"winning_source,omitempty"`
}
type BulkResolveResponse struct {
Results []ConflictResult `json:"results"`
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
}
type ConflictResult struct {
ConflictID string `json:"conflict_id"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Winner string `json:"winner,omitempty"`
}
func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
user := c.Get("user").(database.Users)
var req BulkResolveRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if len(req.ConflictIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "conflict_ids required")
}
results := make([]ConflictResult, 0, len(req.ConflictIDs))
successCount := 0
failedCount := 0
for _, conflictIDStr := range req.ConflictIDs {
conflictID, err := uuid.Parse(conflictIDStr)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid conflict ID",
})
failedCount++
continue
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "conflict not found",
})
failedCount++
continue
}
if conflict.UserID.Bytes != user.ID.Bytes {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "access denied",
})
failedCount++
continue
}
var conflictData map[string]ConflictSourceData
if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to parse conflict data",
})
failedCount++
continue
}
var winningSource string
var winnerData map[string]interface{}
switch req.Strategy {
case "most_recent":
winningSource, winnerData = h.getMostRecentSource(conflictData)
case "highest_progress":
winningSource, winnerData = h.getHighestProgressSource(conflictData)
case "manual":
if req.WinningSource == "" {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "winning_source required for manual strategy",
})
failedCount++
continue
}
source, ok := conflictData[req.WinningSource]
if !ok {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid winning source",
})
failedCount++
continue
}
winningSource = req.WinningSource
winnerData = source.Data
default:
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid strategy",
})
failedCount++
continue
}
if winnerData == nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to determine winner",
})
failedCount++
continue
}
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to apply resolution",
})
failedCount++
continue
}
resolutionData := map[string]interface{}{
"winner": winningSource,
"strategy": req.Strategy,
"resolved_at": time.Now(),
}
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
ResolvedBy: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to mark as resolved",
})
failedCount++
continue
}
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "success",
Winner: winningSource,
})
successCount++
}
return c.JSON(http.StatusOK, BulkResolveResponse{
Results: results,
Total: len(req.ConflictIDs),
Success: successCount,
Failed: failedCount,
})
}
func (h *ConflictHandler) getMostRecentSource(conflictData map[string]ConflictSourceData) (string, map[string]interface{}) {
var recentSource string
var recentTime time.Time
var recentData map[string]interface{}
for source, data := range conflictData {
if data.Timestamp.After(recentTime) {
recentTime = data.Timestamp
recentSource = source
recentData = data.Data
}
}
return recentSource, recentData
}
func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]ConflictSourceData) (string, map[string]interface{}) {
var highestSource string
var highestPercentage float64 = -1
var highestData map[string]interface{}
for source, data := range conflictData {
if percentage, ok := data.Data["percentage"].(float64); ok {
if percentage > highestPercentage {
highestPercentage = percentage
highestSource = source
highestData = data.Data
}
}
}
return highestSource, highestData
}
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
percentage := 0.0
if p, ok := data["percentage"].(float64); ok {
percentage = p
}
var epubcfi pgtype.Text
if e, ok := data["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: e, Valid: true}
}
var chapter pgtype.Int4
if c, ok := data["chapter"].(float64); ok {
chapter = pgtype.Int4{Int32: int32(c), Valid: true}
}
var characterOffset pgtype.Int8
if c, ok := data["character"].(float64); ok {
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
}
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if err == nil {
currentPage = existingProgress.CurrentPage
totalPages = existingProgress.TotalPages
}
if p, ok := data["page"].(float64); ok {
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
}
if p, ok := data["total_pages"].(float64); ok {
totalPages = pgtype.Int4{Int32: int32(p), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{},
PanelNumber: pgtype.Int4{},
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
return err
}
func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
user := c.Get("user").(database.Users)
var req struct {
ConflictIDs []string `json:"conflict_ids" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if len(req.ConflictIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "conflict_ids required")
}
results := make([]ConflictResult, 0, len(req.ConflictIDs))
successCount := 0
failedCount := 0
for _, conflictIDStr := range req.ConflictIDs {
conflictID, err := uuid.Parse(conflictIDStr)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "invalid conflict ID",
})
failedCount++
continue
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "conflict not found",
})
failedCount++
continue
}
if conflict.UserID.Bytes != user.ID.Bytes {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "access denied",
})
failedCount++
continue
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
Error: "failed to dismiss",
})
failedCount++
continue
}
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "success",
})
successCount++
}
return c.JSON(http.StatusOK, BulkResolveResponse{
Results: results,
Total: len(req.ConflictIDs),
Success: successCount,
Failed: failedCount,
})
}