package handlers import ( "bookhoard/internal/database" "fmt" "net/http" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) // HashConflictsHandler serves the admin Hash Conflicts page API: listing // content-duplicate groups (same library + SHA-256 at different paths) and // resolving them by keeping every copy or merging all but one. type HashConflictsHandler struct { db *database.Queries } func NewHashConflictsHandler(db *database.Queries) *HashConflictsHandler { return &HashConflictsHandler{db: db} } // HashConflictItem is one copy in a conflict group, hydrated with per-item // user-data counts so the admin can make an informed keep/merge choice. type HashConflictItem struct { ID uuid.UUID `json:"id"` Title string `json:"title"` Author string `json:"author,omitempty"` FilePath string `json:"file_path"` FileSize int64 `json:"file_size,omitempty"` CreatedAt string `json:"created_at"` ProgressCount int64 `json:"progress_count"` HighlightCount int64 `json:"highlight_count"` BookmarkCount int64 `json:"bookmark_count"` NoteCount int64 `json:"note_count"` CollectionCount int64 `json:"collection_count"` } // HashConflictResponse is one pending conflict group. type HashConflictResponse struct { ID string `json:"id"` LibraryID string `json:"library_id"` LibraryName string `json:"library_name"` SHA256 string `json:"sha256"` CreatedAt string `json:"created_at"` Items []HashConflictItem `json:"items"` } // ListHashConflicts returns all pending hash conflict groups with their member // items and usage counts. // GET /api/admin/hash-conflicts func (h *HashConflictsHandler) ListHashConflicts(c *echo.Context) error { ctx := c.Request().Context() pending, err := h.db.ListPendingHashConflicts(ctx) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "failed to list hash conflicts", }) } conflicts := make([]HashConflictResponse, 0, len(pending)) for _, p := range pending { resp := HashConflictResponse{ ID: uuid.UUID(p.ID.Bytes).String(), LibraryID: uuid.UUID(p.LibraryID.Bytes).String(), LibraryName: p.LibraryName, SHA256: p.FileSha256, CreatedAt: p.CreatedAt.Time.Format(time.RFC3339), Items: []HashConflictItem{}, } items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{ FileSha256: pgtype.Text{String: p.FileSha256, Valid: true}, LibraryID: p.LibraryID, }) if err != nil { continue } for _, mi := range items { counts, err := h.db.GetMediaItemUsageCounts(ctx, mi.ID) if err != nil { counts = database.GetMediaItemUsageCountsRow{} } resp.Items = append(resp.Items, HashConflictItem{ ID: uuid.UUID(mi.ID.Bytes), Title: mi.Title, Author: mi.Author.String, FilePath: mi.FilePath, FileSize: mi.FileSize.Int64, CreatedAt: mi.CreatedAt.Time.Format(time.RFC3339), ProgressCount: counts.ProgressCount, HighlightCount: counts.HighlightsCount, BookmarkCount: counts.BookmarksCount, NoteCount: counts.NotesCount, CollectionCount: counts.CollectionsCount, }) } conflicts = append(conflicts, resp) } return c.JSON(http.StatusOK, map[string]interface{}{ "conflicts": conflicts, "total": len(conflicts), }) } // ResolveHashConflict resolves one conflict group. // // Form/JSON fields: // - action=keep_all both copies are intentional; dismiss // - action=keep&keep_uuid= merge every other copy's child rows into the // kept item (progress, highlights, bookmarks, // notes, collections, ...) and delete the losers // // POST /api/admin/hash-conflicts/:id/resolve func (h *HashConflictsHandler) ResolveHashConflict(c *echo.Context) error { ctx := c.Request().Context() conflictID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conflict ID"}) } pgConflictID := pgtype.UUID{Bytes: conflictID, Valid: true} conflict, err := h.db.GetHashConflict(ctx, pgConflictID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "conflict not found"}) } if conflict.Status != "pending" { return c.JSON(http.StatusConflict, map[string]string{"error": "conflict already resolved"}) } action := c.FormValue("action") keepUUIDStr := c.FormValue("keep_uuid") if action == "" { // Also accept a JSON body (htmx sends form-encoded, API clients may send JSON) var body struct { Action string `json:"action"` KeepUUID string `json:"keep_uuid"` } if err := c.Bind(&body); err == nil && body.Action != "" { action = body.Action if keepUUIDStr == "" { keepUUIDStr = body.KeepUUID } } } var pgUserID pgtype.UUID if userID, ok := c.Get("user_id").(string); ok && userID != "" { if u, err := uuid.Parse(userID); err == nil { pgUserID = pgtype.UUID{Bytes: u, Valid: true} } } switch action { case "keep_all": if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{ ID: pgConflictID, Resolution: pgtype.Text{String: "keep_all", Valid: true}, ResolvedBy: pgUserID, }); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"}) } return renderResolved(c, "All copies kept.") case "keep": keepUUID, err := uuid.Parse(keepUUIDStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is required for action=keep"}) } pgKeepUUID := pgtype.UUID{Bytes: keepUUID, Valid: true} // Validate the kept item belongs to this conflict group. items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{ FileSha256: pgtype.Text{String: conflict.FileSha256, Valid: true}, LibraryID: conflict.LibraryID, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load conflict group"}) } keepValid := false for _, mi := range items { if mi.ID.Bytes == pgKeepUUID.Bytes { keepValid = true break } } if !keepValid { return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is not part of this conflict"}) } merged := 0 for _, mi := range items { if mi.ID.Bytes == pgKeepUUID.Bytes { continue } if err := h.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{ Column1: pgKeepUUID, Column2: mi.ID, }); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": fmt.Sprintf("failed to merge %q: %v", mi.FilePath, err), }) } if err := h.db.DeleteMediaItem(ctx, mi.ID); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": fmt.Sprintf("failed to delete %q: %v", mi.FilePath, err), }) } merged++ } if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{ ID: pgConflictID, Resolution: pgtype.Text{String: "kept:" + keepUUID.String(), Valid: true}, ResolvedBy: pgUserID, }); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"}) } return renderResolved(c, fmt.Sprintf("Merged %d duplicate cop%s - all reading data preserved.", merged, map[bool]string{true: "y", false: "ies"}[merged == 1])) default: return c.JSON(http.StatusBadRequest, map[string]string{"error": "action must be 'keep_all' or 'keep'"}) } } // renderResolved returns the htmx fragment swapped in place of a conflict card. // Built inline (rather than via the templates package) because templates // imports handlers and a back-import would be a cycle. func renderResolved(c *echo.Context, message string) error { html := fmt.Sprintf(`

Conflict resolved

%s

`, message) return c.HTML(http.StatusOK, html) }