Files
mule-image/sidecar/handlers_move.go
dtoro b2b6060872 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>
2026-06-18 00:10:19 +02:00

175 lines
5.3 KiB
Go

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,
})
}
}