Update all handler functions to use *echo.Context (pointer) instead of echo.Context (value) as required by Echo v5. Changes across all handler files: - analytics.go: Update handler signatures - auth.go: Update authentication handler signatures - book_matching.go: Update matching handler signatures - collections.go: Update collection handler signatures - collections_preview_test.go: Update test signatures - commonhandlers.go: Update common handler signatures - conflicts.go: Update conflict handler signatures - context.go: Update context handler signatures - dashboard.go: Update dashboard handler signatures - devices.go: Update device handler signatures - jobs.go: Update job handler signatures - kobo.go: Update Kobo handler signatures - koreader.go: Update Koreader handler signatures - library.go: Update library handler signatures - matching.go: Update matching handler signatures - media.go: Update media handler signatures - opds.go: Update OPDS handler signatures - progress.go: Update progress handler signatures - queue.go: Update queue handler signatures - refresh_token.go: Update token handler signatures - scanner.go: Update scanner handler signatures - sidecar.go: Update sidecar handler signatures - sync.go: Update sync handler signatures - system_settings.go: Update settings handler signatures - websocket.go: Update WebSocket handler signatures All handlers now properly implement Echo v5's pointer-based context pattern. This change is necessary for type safety and compatibility with Echo v5's improved context handling and WebSocket support.
773 lines
22 KiB
Go
773 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
"context"
|
|
"encoding/json"
|
|
"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,oneof=koreader kobo web manual"`
|
|
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 && 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 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 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 != "unresolved" {
|
|
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{}{}
|
|
if req.Winner == "manual" {
|
|
winnerData = req.ManualData
|
|
} 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, winnerData); err == nil {
|
|
appliedTo["progress"] = 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, 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: "conflict_resolution", Valid: true},
|
|
LastSyncSource: pgtype.Text{String: "manual", 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) 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 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.ListSyncConflictsByUser(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, 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,
|
|
})
|
|
}
|