feat(admin): startup hash backfill and hash-conflict resolution API
Release / build-and-push (push) Successful in 2m48s
Release / build-and-push (push) Successful in 2m48s
Complete the SHA-256 lifecycle for preexisting databases: items imported before hashing existed get hashed automatically, and any content duplicates discovered in the process land on the new admin Hash Conflicts page for an explicit keep/merge decision. HashBackfillService (runs once 30s after startup, independent of auto-scan): - hashes every media_items row where file_sha256 IS NULL, resolving each path through LibraryService; per-item failures are logged and skipped so one unreadable file cannot block the pass - no-op once everything is hashed (logged and skipped) - finishes with a conflict sweep flagging every content-duplicate group via FindHashConflictGroups + CreateHashConflict; the sweep runs after the per-item pass because a preexisting pair only becomes detectable once both sides have their hash API (admin-only): - GET /api/admin/hash-conflicts - pending groups with member items and usage counts - POST /api/admin/hash-conflicts/:id/resolve - action=keep_all, or action=keep with keep_uuid: validates the uuid belongs to the group, re-parents every other copy's child rows onto the kept item (reparent_media_item_children), deletes the losers, and records the resolution + resolving admin; accepts form or JSON bodies and returns the htmx resolved fragment Page route /admin/hash-conflicts (admin-only) renders the template with hydrated conflict data; HashConflictsHandler wired into the router Config and constructed in main. Verified end-to-end against the live database: duplicate detection, pending listing, keep_all resolution, merge path (re-parent + delete), and - critically - a resolved group is not re-flagged by a later sweep (upsert no-op). Database restored afterward.
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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=<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(`
|
||||
<div class="card p-6 flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0"
|
||||
style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary);">Conflict resolved</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">%s</p>
|
||||
</div>
|
||||
</div>`, message)
|
||||
return c.HTML(http.StatusOK, html)
|
||||
}
|
||||
Reference in New Issue
Block a user