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.
256 lines
8.6 KiB
Go
256 lines
8.6 KiB
Go
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)
|
|
}
|