feat(review): fast keyboard-first Stacks & Duplicates resolve queue
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>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sanitizeFilename trims a user-supplied filename and rejects anything
|
||||
@@ -168,6 +169,7 @@ type fileEntry struct {
|
||||
RelPath string
|
||||
AbsPath string
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
|
||||
@@ -222,6 +224,7 @@ func walkFiles(root string) ([]fileEntry, error) {
|
||||
RelPath: rel,
|
||||
AbsPath: p,
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -22,6 +22,9 @@ 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 {
|
||||
@@ -128,7 +131,11 @@ func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
g := dupGroup{Hash: h, Size: hashSize[h]}
|
||||
for _, f := range files {
|
||||
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
|
||||
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).
|
||||
@@ -271,3 +278,95 @@ func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ func main() {
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
||||
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
|
||||
|
||||
// User-scoped proxies — require PpDSN connection.
|
||||
if ppDb != nil {
|
||||
|
||||
Reference in New Issue
Block a user