feat(move): "move to folder" for grid selections, folders, and m shortcut
Extends the heap-only "move to folder" action to grid single/bulk selections, sidebar folders, and an `m` keyboard shortcut — all through one shared dialog driven by a moveDialog store. Backend (sidecar): - Extract the heap move/copy + reindex loop into a reusable movePhotoFiles helper plus resolveMoveTarget - POST /photos/move: move/copy an arbitrary UID list into a folder - POST /folders/:rel/move: reparent a folder dir (whole subtree) under a new parent, guarding against moving into itself/a descendant Frontend: - moveDialog store + generalized MoveToFolderDialog (heap | photos | folder subjects); mounted once in +layout.svelte. Replaces HeapConvertDialog - movePhotosToFolder / moveFolder service fns - Entry points: BulkActionBar button, gridKeyNav `m`, FolderTree kebab, heap kebab — all call openMove() Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -83,32 +83,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
subfolder = s
|
||||
}
|
||||
|
||||
// Resolve destination. resolveUnderRoot ensures the target lives
|
||||
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
||||
// Empty / "/" / "." are valid here — they mean "drop these into
|
||||
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
|
||||
// rejects those for safety, so handle the root case explicitly.
|
||||
var targetAbs string
|
||||
trimmed := strings.Trim(body.TargetFolder, "/")
|
||||
if trimmed == "" || trimmed == "." {
|
||||
targetAbs = cfg.OriginalsRoot
|
||||
} else {
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
targetAbs = abs
|
||||
// Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
|
||||
// "drop these into originals/ itself" (the modal's "Root" option).
|
||||
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
destAbs := targetAbs
|
||||
if subfolder != "" {
|
||||
destAbs = filepath.Join(targetAbs, subfolder)
|
||||
if err := os.MkdirAll(destAbs, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Pull the heap's photos via the q=album:UID query. count=1000 covers
|
||||
// every realistic heap; merged=true expands stacked variants so we
|
||||
// move the JPG/HEIC sibling alongside the primary.
|
||||
@@ -129,107 +110,10 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
sourceParents := map[string]struct{}{}
|
||||
errs := []heapErr{}
|
||||
moved, copied := 0, 0
|
||||
|
||||
for _, photo := range photos {
|
||||
// Pick the file to physically move. PhotoPrism's "primary" file
|
||||
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
||||
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
||||
// — moving that path would fail "file missing on disk" every
|
||||
// time. Prefer the primary that lives in originals (Root=="/")
|
||||
// and fall back to the first originals-rooted file. PhotoPrism
|
||||
// regenerates sidecars on reindex, so they don't need to follow.
|
||||
var file ppFile
|
||||
found := false
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" && f.Primary {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||
continue
|
||||
}
|
||||
srcRel := file.Name
|
||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
||||
continue
|
||||
}
|
||||
st, err := os.Stat(srcAbs)
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
||||
continue
|
||||
}
|
||||
if filepath.Dir(srcAbs) == destAbs {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||
continue
|
||||
}
|
||||
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
|
||||
if !ok {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||
continue
|
||||
}
|
||||
dstAbs := filepath.Join(destAbs, name)
|
||||
if mode == "move" {
|
||||
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
||||
// Cross-device renames fail with EXDEV — fall back to
|
||||
// copy+remove so a library that spans filesystems still
|
||||
// works.
|
||||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
moved++
|
||||
} else {
|
||||
if err := copyFile(srcAbs, dstAbs); err != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
copied++
|
||||
}
|
||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||
}
|
||||
|
||||
// Reindex the destination + every source parent so PhotoPrism's
|
||||
// DB catches up. We block on these so the response only goes out
|
||||
// after the index reflects the move — callers (the frontend's
|
||||
// invalidateQueries refetch in particular) need the next /photos
|
||||
// fetch to return the moved files, otherwise the folder view
|
||||
// looks unchanged. PhotoPrism's index endpoint serialises calls
|
||||
// internally; running them sequentially matches that contract
|
||||
// without surprising the server.
|
||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||
paths := map[string]struct{}{destRel: {}}
|
||||
for p := range sourceParents {
|
||||
paths[p] = struct{}{}
|
||||
}
|
||||
if subfolder != "" {
|
||||
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
||||
paths[parent] = struct{}{}
|
||||
}
|
||||
for p := range paths {
|
||||
reindex := "/"
|
||||
if p != "" && p != "." {
|
||||
reindex = "/" + p
|
||||
}
|
||||
fireReindex(cfg, pp, token, reindex)
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
heapDeleted := false
|
||||
@@ -260,3 +144,133 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// movePhotoFiles moves (or copies) each photo's originals-rooted primary file
|
||||
// into targetAbs — optionally into `subfolder` under it — then blocks on a
|
||||
// PhotoPrism reindex of the destination plus every source parent so the next
|
||||
// /photos fetch reflects the move. Shared by handleHeapConvert (album-scoped)
|
||||
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
|
||||
// but move them identically. Returns per-photo errors in `errs`; the returned
|
||||
// top-level error is only for a fatal precondition (subfolder mkdir failed).
|
||||
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
|
||||
destAbs := targetAbs
|
||||
if subfolder != "" {
|
||||
destAbs = filepath.Join(targetAbs, subfolder)
|
||||
if e := os.MkdirAll(destAbs, 0o755); e != nil {
|
||||
return 0, 0, nil, e
|
||||
}
|
||||
}
|
||||
|
||||
sourceParents := map[string]struct{}{}
|
||||
errs = []heapErr{}
|
||||
|
||||
for _, photo := range photos {
|
||||
// Pick the file to physically move. PhotoPrism's "primary" file
|
||||
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
||||
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
||||
// — moving that path would fail "file missing on disk" every
|
||||
// time. Prefer the primary that lives in originals (Root=="/")
|
||||
// and fall back to the first originals-rooted file. PhotoPrism
|
||||
// regenerates sidecars on reindex, so they don't need to follow.
|
||||
var file ppFile
|
||||
found := false
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" && f.Primary {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||
continue
|
||||
}
|
||||
srcRel := file.Name
|
||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
||||
continue
|
||||
}
|
||||
st, statErr := os.Stat(srcAbs)
|
||||
if statErr != nil || !st.Mode().IsRegular() {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
||||
continue
|
||||
}
|
||||
if filepath.Dir(srcAbs) == destAbs {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||
continue
|
||||
}
|
||||
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
|
||||
if !ok {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||
continue
|
||||
}
|
||||
dstAbs := filepath.Join(destAbs, name)
|
||||
if mode == "move" {
|
||||
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
|
||||
// Cross-device renames fail with EXDEV — fall back to
|
||||
// copy+remove so a library that spans filesystems still
|
||||
// works.
|
||||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.Error()})
|
||||
continue
|
||||
}
|
||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
moved++
|
||||
} else {
|
||||
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
|
||||
continue
|
||||
}
|
||||
copied++
|
||||
}
|
||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||
}
|
||||
|
||||
// Reindex the destination + every source parent so PhotoPrism's DB
|
||||
// catches up. We block on these so the response only goes out after the
|
||||
// index reflects the move — the frontend's invalidateQueries refetch
|
||||
// needs the next /photos fetch to return the moved files, otherwise the
|
||||
// folder view looks unchanged. PhotoPrism's index endpoint serialises
|
||||
// calls internally; running them sequentially matches that contract.
|
||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||
paths := map[string]struct{}{destRel: {}}
|
||||
for p := range sourceParents {
|
||||
paths[p] = struct{}{}
|
||||
}
|
||||
if subfolder != "" {
|
||||
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
||||
paths[parent] = struct{}{}
|
||||
}
|
||||
for p := range paths {
|
||||
reindex := "/"
|
||||
if p != "" && p != "." {
|
||||
reindex = "/" + p
|
||||
}
|
||||
fireReindex(cfg, pp, token, reindex)
|
||||
}
|
||||
|
||||
return moved, copied, errs, nil
|
||||
}
|
||||
|
||||
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
|
||||
// mean the Originals root itself) into a validated absolute path under the
|
||||
// root. Shared by the heap-convert and photos-move destination handling.
|
||||
func resolveMoveTarget(cfg *Config, targetFolder string) (string, error) {
|
||||
trimmed := strings.Trim(targetFolder, "/")
|
||||
if trimmed == "" || trimmed == "." {
|
||||
return cfg.OriginalsRoot, nil
|
||||
}
|
||||
return resolveUnderRoot(cfg.OriginalsRoot, targetFolder, true)
|
||||
}
|
||||
|
||||
174
sidecar/handlers_move.go
Normal file
174
sidecar/handlers_move.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type photosMoveBody struct {
|
||||
UIDs []string `json:"uids"`
|
||||
TargetFolder string `json:"targetFolder"`
|
||||
Mode string `json:"mode"` // "move" or "copy"
|
||||
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
||||
}
|
||||
|
||||
// handlePhotosMove moves/copies an arbitrary list of photos (by UID) into a
|
||||
// folder under originals/. Mirrors handleHeapConvert but resolves the photos
|
||||
// from a UID list instead of an album query, then shares movePhotoFiles for
|
||||
// the on-disk work + reindex. Backs the grid's "Move to folder" action.
|
||||
func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
|
||||
var body photosMoveBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if len(body.UIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no uids"})
|
||||
return
|
||||
}
|
||||
mode := body.Mode
|
||||
if mode != "copy" {
|
||||
mode = "move"
|
||||
}
|
||||
|
||||
var subfolder string
|
||||
if body.Subfolder != "" {
|
||||
s, ok := sanitizeFilename(body.Subfolder)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
|
||||
return
|
||||
}
|
||||
subfolder = s
|
||||
}
|
||||
|
||||
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the photos via a single q=uid:a|b|c query. PhotoPrism's
|
||||
// search treats `|` as OR within a filter value, so one round-trip
|
||||
// covers the whole selection; merged=true pulls stacked variants so
|
||||
// the JPG/HEIC sibling travels with its primary.
|
||||
q := url.QueryEscape("uid:" + strings.Join(body.UIDs, "|"))
|
||||
listURL := "/api/v1/photos?q=" + q + "&count=" + itoa(len(body.UIDs)) + "&merged=true"
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.OK {
|
||||
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
|
||||
return
|
||||
}
|
||||
var photos []heapPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
||||
return
|
||||
}
|
||||
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("photos.move",
|
||||
"requested", len(body.UIDs),
|
||||
"mode", mode,
|
||||
"moved", moved,
|
||||
"copied", copied,
|
||||
"errors", len(errs),
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"moved": moved,
|
||||
"copied": copied,
|
||||
"errors": errs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type folderMoveBody struct {
|
||||
// Originals-relative destination parent. ""/"/"/"." mean the root.
|
||||
TargetParent string `json:"targetParent"`
|
||||
}
|
||||
|
||||
// handleFolderMove reparents a folder: moves the directory (and everything in
|
||||
// it) under a different parent, keeping its own name. Mirrors
|
||||
// handleFolderRename but the destination is a parent folder rather than a new
|
||||
// name. A whole-tree os.Rename preserves subfolder structure.
|
||||
func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
rel, ok := pathParam(c, "rel")
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
var body folderMoveBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
targetParentAbs, err := resolveMoveTarget(cfg, body.TargetParent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
||||
return
|
||||
}
|
||||
// Can't move a folder into itself or one of its own descendants.
|
||||
if sameOrUnder(targetParentAbs, oldAbs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
|
||||
return
|
||||
}
|
||||
newAbs := filepath.Join(targetParentAbs, filepath.Base(oldAbs))
|
||||
if newAbs == oldAbs {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already in that folder"})
|
||||
return
|
||||
}
|
||||
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(newAbs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
|
||||
return
|
||||
}
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
|
||||
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
|
||||
slog.Info("folder.move", "from", oldRel, "to", newRel)
|
||||
// Reindex both the old and new parents so PhotoPrism drops the moved
|
||||
// rows from the source view and picks them up under the destination.
|
||||
fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
|
||||
fireReindex(cfg, pp, token, "/"+filepath.Dir(newRel))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"oldPath": oldRel,
|
||||
"newPath": newRel,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,11 @@ func main() {
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||
|
||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
|
||||
Reference in New Issue
Block a user