diff --git a/internal/handlers/conflicts.go b/internal/handlers/conflicts.go index 579b9f7..e770854 100644 --- a/internal/handlers/conflicts.go +++ b/internal/handlers/conflicts.go @@ -74,19 +74,23 @@ func (h *ConflictHandler) GetConflictsData(c echo.Context) ([]ConflictDetailResp ctx := context.Background() - conflicts, err := h.db.ListAllConflictsByUserAndStatus(ctx, database.ListAllConflictsByUserAndStatusParams{ - UserID: user.ID, - ResolutionStatus: pgtype.Text{String: status, Valid: true}, - }) + 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 && err != pgx.ErrNoRows { return nil, 0, 0, err } - response := make([]ConflictDetailResponse, 0, len(conflicts)) + response := make([]ConflictDetailResponse, 0, len(conflicts.([]database.ListSyncConflictsByUserRow))) unresolvedCount := 0 - for _, conflict := range conflicts { + for _, conflict := range conflicts.([]database.ListSyncConflictsByUserRow) { var conflictData map[string]ConflictSourceData if err := json.Unmarshal(conflict.ConflictData, &conflictData); err != nil { continue @@ -392,18 +396,17 @@ func (h *ConflictHandler) DeleteConflict(c echo.Context) error { func (h *ConflictHandler) DismissAllResolved(c echo.Context) error { user := c.Get("user").(database.Users) - conflicts, err := h.db.ListAllConflictsByUserAndStatus(context.Background(), database.ListAllConflictsByUserAndStatusParams{ - UserID: user.ID, - ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true}, - }) + conflicts, err := h.db.ListSyncConflictsByUser(context.Background(), user.ID) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts") } deleted := 0 for _, conflict := range conflicts { - if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil { - deleted++ + if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" { + if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil { + deleted++ + } } } @@ -411,3 +414,359 @@ func (h *ConflictHandler) DismissAllResolved(c echo.Context) error { "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, 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, data map[string]interface{}) error { + ctx := context.Background() + + existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{ + MediaItemID: mediaItemID, + UserID: userID, + }) + if err != nil && 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} + } + + 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: "bulk_resolution", Valid: true}, + LastSyncSource: pgtype.Text{String: "bulk", 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, + }) +}