Files
H1K0 78b5e86dd6 feat(backend): report stored pairwise distances in the duplicates response
ListVisible already loads each pair's Hamming distance, but clusterPairs threw
it away. Thread it through: Clusters now returns a Cluster carrying the stored
pairwise distances (indexed once per page), and the list endpoint emits them as
{a, b, distance}. Pairs linked only transitively have no stored distance and are
omitted. Lets the UI show how close each file is to the kept one without a
client-side hash compare (phash exceeds JS's safe-integer range).
2026-06-22 22:42:14 +03:00

127 lines
3.3 KiB
Go

package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
)
// DuplicateHandler handles the /files/duplicates endpoints.
type DuplicateHandler struct {
dupSvc *service.DuplicateService
}
// NewDuplicateHandler creates a DuplicateHandler.
func NewDuplicateHandler(dupSvc *service.DuplicateService) *DuplicateHandler {
return &DuplicateHandler{dupSvc: dupSvc}
}
// List handles GET /files/duplicates — an offset-paginated list of duplicate
// clusters, each a group of files within the perceptual-hash threshold.
func (h *DuplicateHandler) List(c *gin.Context) {
limit, offset := 20, 0
if n, err := strconv.Atoi(c.Query("limit")); err == nil {
limit = n
}
if n, err := strconv.Atoi(c.Query("offset")); err == nil {
offset = n
}
if limit < 1 {
limit = 1
}
if limit > 50 {
limit = 50
}
if offset < 0 {
offset = 0
}
clusters, total, err := h.dupSvc.Clusters(c.Request.Context(), limit, offset)
if err != nil {
respondError(c, err)
return
}
items := make([]gin.H, len(clusters))
for i, cl := range clusters {
fs := make([]fileJSON, len(cl.Files))
for j, f := range cl.Files {
fs[j] = toFileJSON(f)
}
dists := make([]gin.H, len(cl.Distances))
for j, d := range cl.Distances {
dists[j] = gin.H{"a": d.A, "b": d.B, "distance": d.Distance}
}
items[i] = gin.H{"files": fs, "distances": dists}
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// Dismiss handles POST /files/duplicates/dismiss — mark a pair "not a duplicate".
func (h *DuplicateHandler) Dismiss(c *gin.Context) {
var body struct {
FileIDA string `json:"file_id_a" binding:"required"`
FileIDB string `json:"file_id_b" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
ids, err := parseUUIDs([]string{body.FileIDA, body.FileIDB})
if err != nil {
respondError(c, domain.ErrValidation)
return
}
if err := h.dupSvc.Dismiss(c.Request.Context(), ids[0], ids[1]); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// Resolve handles POST /files/duplicates/resolve — merge a duplicate pair,
// keeping one file and folding the chosen fields in from the other. Returns the
// updated survivor. delete_discarded defaults to true.
func (h *DuplicateHandler) Resolve(c *gin.Context) {
var body struct {
Keep string `json:"keep" binding:"required"`
Discard string `json:"discard" binding:"required"`
Fields service.MergeFields `json:"fields"`
DeleteDiscarded *bool `json:"delete_discarded"`
}
if err := c.ShouldBindJSON(&body); err != nil {
respondError(c, domain.ErrValidation)
return
}
ids, err := parseUUIDs([]string{body.Keep, body.Discard})
if err != nil {
respondError(c, domain.ErrValidation)
return
}
del := true
if body.DeleteDiscarded != nil {
del = *body.DeleteDiscarded
}
f, err := h.dupSvc.Resolve(c.Request.Context(), service.MergeSpec{
Keep: ids[0],
Discard: ids[1],
Fields: body.Fields,
DeleteDiscarded: del,
})
if err != nil {
respondError(c, err)
return
}
respondJSON(c, http.StatusOK, toFileJSON(*f))
}