feat(conflicts): add bulk resolve and dismiss operations
- BulkResolveConflicts: resolve multiple conflicts with configurable strategies - most_recent: choose most recently updated source - highest_progress: choose source with highest reading progress - manual: use specified winning source - BulkDismissConflicts: dismiss multiple resolved conflicts at once - ResolveHighestProgress: convenience endpoint for high-progress strategy - Return detailed results for each operation
This commit is contained in:
+371
-12
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user