Both tabs get a resolve-and-advance queue instead of independent click-to-focus cards: ↑/↓ or j/k rove between groups, resolving a group removes it optimistically and auto-advances focus, and a sticky header tracks reclaimable bytes + a running "resolved this session" tally. ⌘Z undoes via a new sidecar restore endpoint (gridKeyNav — the usual ⌘Z owner — isn't mounted on these tabs, so DuplicatesView wires its own). Sidecar (handlers_dups.go, fs.go, main.go): - POST /duplicates/restore — inverse of /duplicates/archive, moves quarantined files back to their original path with the same BasePath guards and async reindex-with-cleanup. - Scan results now include each file's mtime so the UI can label older/newer copies. Stack losers now go through the same sidecar quarantine as cross-folder duplicates (setPrimary + archiveDuplicatePaths) instead of a hard PhotoPrism DELETE, so both tabs share one recoverable, undoable resolution path (services/duplicateActions.svelte.ts). StackGroupCard: comparison-first — fact rows highlight the best size/resolution per file, a "Suggested" badge appears when one file wins outright, and Space opens a fullscreen CompareLightbox that flips between candidates while preserving zoom/pan (extracted the zoom/pan gesture handling from PreviewPane into a shared lib/actions/zoomPan.ts action so both consumers share one implementation). CrossFolderGroupCard: since every copy is byte-identical, the old grid of N identical thumbnails told the user nothing — replaced with one thumbnail plus a path list that highlights the differing folder segment and flags the indexed/newest copy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
373 lines
12 KiB
Go
373 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const quarantineDir = ".duplicates"
|
||
|
||
type dupFileLite struct {
|
||
Path string `json:"path"`
|
||
Size int64 `json:"size"`
|
||
// RFC3339 mtime so the UI can label older/newer copies. Copies are
|
||
// byte-identical, so mtime is the only per-copy signal besides path.
|
||
ModTime string `json:"modTime,omitempty"`
|
||
}
|
||
|
||
type dupGroup struct {
|
||
Hash string `json:"hash"`
|
||
Size int64 `json:"size"`
|
||
IndexedPath *string `json:"indexedPath"`
|
||
Files []dupFileLite `json:"files"`
|
||
}
|
||
|
||
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
||
// looking up "which file path has this hash already indexed", used to
|
||
// hint the UI which copy to keep.
|
||
type dupListPhoto struct {
|
||
Files []ppFile `json:"Files"`
|
||
}
|
||
|
||
func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ctxToken(c)
|
||
start := time.Now()
|
||
|
||
// Scope the walk to the user's effective library root (BasePath +
|
||
// chosen index sub-path), same as the folders/timeline/reindex scope —
|
||
// otherwise a narrowed root would still surface every other user's
|
||
// files in the cross-folder duplicate scan. "" means whole library
|
||
// (today's admin-without-BasePath default).
|
||
root := effectiveLibraryRoot(c, db)
|
||
scanRoot := cfg.OriginalsRoot
|
||
if root != "" {
|
||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, root, true)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid library root"})
|
||
return
|
||
}
|
||
scanRoot = abs
|
||
}
|
||
slog.Info("dup.scan starting", "root", scanRoot)
|
||
|
||
all, err := walkFiles(scanRoot)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
// walkFiles computes RelPath relative to scanRoot; re-prefix with the
|
||
// scoped sub-path so RelPath stays originals-root-relative, matching
|
||
// what handleDupArchive (and the rest of the API) expects.
|
||
if root != "" {
|
||
for i := range all {
|
||
all[i].RelPath = root + "/" + all[i].RelPath
|
||
}
|
||
}
|
||
|
||
// Group by size first: byte-identical files necessarily share size,
|
||
// so size-collision is a cheap O(N) prefilter that lets us skip
|
||
// hashing >95% of a typical library.
|
||
bySize := map[int64][]fileEntry{}
|
||
for _, f := range all {
|
||
bySize[f.Size] = append(bySize[f.Size], f)
|
||
}
|
||
|
||
// Hash size-collision buckets concurrently. Cap fan-out to GOMAXPROCS
|
||
// so we don't drown the disk with parallel reads on a spinning HDD.
|
||
type hashOut struct {
|
||
hash string
|
||
f fileEntry
|
||
}
|
||
var (
|
||
wg sync.WaitGroup
|
||
sem = make(chan struct{}, 4)
|
||
outMu sync.Mutex
|
||
byHash = map[string][]fileEntry{}
|
||
hashSize = map[string]int64{}
|
||
)
|
||
for size, group := range bySize {
|
||
if len(group) < 2 {
|
||
continue
|
||
}
|
||
for _, f := range group {
|
||
wg.Add(1)
|
||
sem <- struct{}{}
|
||
go func(f fileEntry, sz int64) {
|
||
defer wg.Done()
|
||
defer func() { <-sem }()
|
||
h, err := sha1File(f.AbsPath)
|
||
if err != nil {
|
||
return
|
||
}
|
||
outMu.Lock()
|
||
byHash[h] = append(byHash[h], f)
|
||
hashSize[h] = sz
|
||
outMu.Unlock()
|
||
}(f, size)
|
||
}
|
||
}
|
||
wg.Wait()
|
||
|
||
// Drop singletons (size collision but different hashes), then ask
|
||
// PhotoPrism which of the duplicates it has indexed so the UI can
|
||
// default the "keep" selection to that one.
|
||
groups := make([]dupGroup, 0)
|
||
for h, files := range byHash {
|
||
if len(files) < 2 {
|
||
continue
|
||
}
|
||
g := dupGroup{Hash: h, Size: hashSize[h]}
|
||
for _, f := range files {
|
||
g.Files = append(g.Files, dupFileLite{
|
||
Path: f.RelPath,
|
||
Size: f.Size,
|
||
ModTime: f.ModTime.UTC().Format(time.RFC3339),
|
||
})
|
||
}
|
||
// Best-effort lookup; swallow errors. The hash query is cheap on
|
||
// PhotoPrism's side (indexed column).
|
||
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||
"/api/v1/photos?q=hash:"+h+"&count=1&merged=true", token, nil)
|
||
if err == nil && resp.OK {
|
||
var photos []dupListPhoto
|
||
if err := json.Unmarshal(resp.Body, &photos); err == nil && len(photos) > 0 {
|
||
if pf, ok := primaryFileOf(&ppPhoto{Files: photos[0].Files}); ok && pf.Name != "" {
|
||
p := pf.Name
|
||
g.IndexedPath = &p
|
||
}
|
||
}
|
||
}
|
||
groups = append(groups, g)
|
||
}
|
||
// Sort by reclaimable bytes descending (size × duplicate-count) so
|
||
// the biggest wins float to the top of the UI.
|
||
sort.Slice(groups, func(i, j int) bool {
|
||
return groups[i].Size*int64(len(groups[i].Files)-1) >
|
||
groups[j].Size*int64(len(groups[j].Files)-1)
|
||
})
|
||
|
||
ms := time.Since(start).Milliseconds()
|
||
slog.Info("dup.scan done", "groups", len(groups), "ms", ms)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"groups": groups,
|
||
"scannedMs": ms,
|
||
})
|
||
}
|
||
}
|
||
|
||
type dupArchiveBody struct {
|
||
Paths []string `json:"paths"`
|
||
}
|
||
|
||
type dupMoved struct {
|
||
From string `json:"from"`
|
||
To string `json:"to"`
|
||
}
|
||
|
||
type dupArchiveErr struct {
|
||
Path string `json:"path"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ctxToken(c)
|
||
var body dupArchiveBody
|
||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Paths) == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "paths[] required"})
|
||
return
|
||
}
|
||
|
||
// Authz: every path must live under the caller's effective library
|
||
// root. The scan above already only ever returns paths from there,
|
||
// but this endpoint takes paths straight from the request body, so a
|
||
// scoped (non-admin, or admin-with-sub-path) user could otherwise
|
||
// pass an arbitrary originals-relative path and archive (move) files
|
||
// outside their own folder.
|
||
root := effectiveLibraryRoot(c, db)
|
||
if root != "" {
|
||
for _, p := range body.Paths {
|
||
clean := strings.Trim(p, "/")
|
||
if clean != root && !strings.HasPrefix(clean, root+"/") {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Each archive batch lands in its own timestamped subdir so the
|
||
// user can browse what was quarantined when (and recover by hand
|
||
// if they change their mind).
|
||
stamp := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
|
||
targetDir := filepath.Join(cfg.OriginalsRoot, quarantineDir, stamp)
|
||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// archiveOne moves a single file into the quarantine batch dir
|
||
// and returns the new relative path. Disambiguates same-basename
|
||
// collisions within the batch so two `IMG_0001.jpg` from
|
||
// different folders don't clobber each other.
|
||
archiveOne := func(rel string) (string, error) {
|
||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||
if err != nil {
|
||
return "", errors.New("invalid path")
|
||
}
|
||
base := filepath.Base(abs)
|
||
dest := filepath.Join(targetDir, base)
|
||
for i := 1; ; i++ {
|
||
if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
|
||
break
|
||
} else if err != nil {
|
||
return "", err
|
||
}
|
||
stem := base[:len(base)-len(filepath.Ext(base))]
|
||
dest = filepath.Join(targetDir, stem+"__"+itoa(i)+filepath.Ext(base))
|
||
}
|
||
if err := os.Rename(abs, dest); err != nil {
|
||
// EXDEV fallback — copy+remove for libraries that span
|
||
// filesystems (e.g. originals on a different mount).
|
||
if err2 := copyFile(abs, dest); err2 != nil {
|
||
return "", err
|
||
}
|
||
if err2 := os.Remove(abs); err2 != nil {
|
||
return "", errors.New("moved but source remove failed: " + err2.Error())
|
||
}
|
||
}
|
||
relDest, _ := filepath.Rel(cfg.OriginalsRoot, dest)
|
||
return relDest, nil
|
||
}
|
||
|
||
moved := []dupMoved{}
|
||
errs := []dupArchiveErr{}
|
||
for _, rel := range body.Paths {
|
||
relDest, err := archiveOne(rel)
|
||
if err != nil {
|
||
errs = append(errs, dupArchiveErr{Path: rel, Error: err.Error()})
|
||
continue
|
||
}
|
||
moved = append(moved, dupMoved{From: rel, To: relDest})
|
||
slog.Info("dup.archive", "from", rel, "to", relDest)
|
||
}
|
||
|
||
// Reindex the entire library so PhotoPrism drops rows for the
|
||
// archived files. cleanup:true is critical — the files still
|
||
// exist on disk, just under .duplicates/ which the indexer
|
||
// ignores.
|
||
if len(moved) > 0 {
|
||
go func() {
|
||
if err := pp.reindex(context.Background(), token, "/"); err != nil {
|
||
slog.Warn("dup.archive reindex failed", "err", err)
|
||
}
|
||
}()
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
|
||
}
|
||
}
|
||
|
||
type dupRestoreBody struct {
|
||
// Moves mirror the archive response's {from,to} pairs verbatim; the
|
||
// handler renames each `to` (quarantine path) back to its `from`.
|
||
Moves []dupMoved `json:"moves"`
|
||
}
|
||
|
||
// handleDupRestore is the inverse of handleDupArchive: it moves files
|
||
// out of `.duplicates/<ts>/` back to their original paths. It exists so
|
||
// the web client can offer real undo for duplicate/stack resolution —
|
||
// quarantine is only trustworthy if backing out is one keystroke.
|
||
func handleDupRestore(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ctxToken(c)
|
||
var body dupRestoreBody
|
||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
|
||
return
|
||
}
|
||
|
||
// Authz mirrors handleDupArchive: every destination (`from`) must
|
||
// live under the caller's effective library root, and every source
|
||
// (`to`) must live inside the quarantine dir — otherwise this
|
||
// endpoint would double as an arbitrary-move tool.
|
||
root := effectiveLibraryRoot(c, db)
|
||
for _, m := range body.Moves {
|
||
src := strings.Trim(m.To, "/")
|
||
if src != quarantineDir && !strings.HasPrefix(src, quarantineDir+"/") {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "source not in quarantine"})
|
||
return
|
||
}
|
||
if root != "" {
|
||
dst := strings.Trim(m.From, "/")
|
||
if dst != root && !strings.HasPrefix(dst, root+"/") {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
restoreOne := func(m dupMoved) error {
|
||
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
|
||
if err != nil {
|
||
return errors.New("invalid quarantine path")
|
||
}
|
||
// The destination must not exist yet — mustExist=false resolves
|
||
// the path without requiring it on disk, and the Stat below
|
||
// refuses to clobber anything that reappeared in the meantime.
|
||
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
|
||
if err != nil {
|
||
return errors.New("invalid destination path")
|
||
}
|
||
if _, err := os.Stat(dstAbs); err == nil {
|
||
return errors.New("destination already exists")
|
||
} else if !errors.Is(err, os.ErrNotExist) {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||
return err
|
||
}
|
||
if err2 := os.Remove(srcAbs); err2 != nil {
|
||
return errors.New("restored but quarantine copy remove failed: " + err2.Error())
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
restored := []dupMoved{}
|
||
errs := []dupArchiveErr{}
|
||
for _, m := range body.Moves {
|
||
if err := restoreOne(m); err != nil {
|
||
errs = append(errs, dupArchiveErr{Path: m.To, Error: err.Error()})
|
||
continue
|
||
}
|
||
restored = append(restored, dupMoved{From: m.To, To: m.From})
|
||
slog.Info("dup.restore", "from", m.To, "to", m.From)
|
||
}
|
||
|
||
if len(restored) > 0 {
|
||
go func() {
|
||
if err := pp.reindex(context.Background(), token, "/"); err != nil {
|
||
slog.Warn("dup.restore reindex failed", "err", err)
|
||
}
|
||
}()
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
|
||
}
|
||
}
|