Backend correctness was already sound (companion files travel as a
group, coordinated collision suffixes, EXDEV fallback, per-file scope
checks, blocking scoped reindex) — this pass adds reversibility and
brings the modal up to standard.
Sidecar:
- movePhotoFiles records per-file {from,to} pairs (move mode) —
including siblings of photos that failed partway, since undo must
restore whatever actually left its folder. Both POST /photos/move
and POST /albums/:uid/convert return them as movedFiles.
- New POST /files/restore-moves plays those pairs backwards: both ends
scope-checked (sources aren't quarantined like the duplicates
restore), never clobbers an existing destination, EXDEV fallback,
blocking reindex of affected parents so the client's refetch already
sees the restored layout.
Dialog (all three subjects — photos, heap convert, folder reparent):
- Search field on top (autofocused) filtering the tree live: matches +
ancestors, force-expanded without touching the sidebar's persisted
open/collapse state (new FolderTree forceExpand prop).
- Arrow keys rove through visible rows with selection following focus
(data-move-row attributes in FolderTree's readonly picker mode);
Enter confirms from anywhere once a destination is set.
- Recent destinations as one-click chips (last 5, per library base).
- Live destination preview line and count-labeled confirm buttons
("Move 12 photos", "Move “2024”") with a disabled-reason tooltip.
- Client-side subfolder validation mirroring the sidecar's
sanitizeFilename rules (inline error, aria-invalid, confirm gated).
- Pre-disables Move when every selected photo is already in the target.
- Undo everywhere it's safe: photo/heap moves restore via the new
endpoint, folder moves invert to another folder move, copies stay
toast-only (their inverse would be deletion). Success toasts carry
an inline Undo action; ⌘Z works through the shared undo stack.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
300 lines
10 KiB
Go
300 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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
|
|
}
|
|
if !requireUserScope(c, cfg, targetAbs, false) {
|
|
return
|
|
}
|
|
|
|
// Resolve each photo's FULL file list via the single-photo endpoint
|
|
// rather than the /photos search (see resolvePhotosFull) — the search
|
|
// drops a photo's video file from its trimmed Files array and filters
|
|
// videos out by quality/review, so the .mov never gets listed to move.
|
|
photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, body.UIDs)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
// Surface UIDs PhotoPrism couldn't resolve alongside any per-file
|
|
// errors so the client's "N skipped" summary stays accurate.
|
|
errs = append(resolveErrs, errs...)
|
|
|
|
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,
|
|
"movedFiles": movedPairs,
|
|
"errors": errs,
|
|
})
|
|
}
|
|
}
|
|
|
|
// resolvePhotosFull fetches each photo's complete file list via the
|
|
// single-photo endpoint (GET /photos/:uid). Use this instead of the /photos
|
|
// search whenever you need every file of a photo: the search — even with
|
|
// merged=true — can return a trimmed Files array that omits the photo's video
|
|
// file, and it applies PhotoPrism's default quality/review/archive filters.
|
|
// Both silently drop videos (which PhotoPrism routinely files under review)
|
|
// from a move. The per-UID lookup returns every file and ignores those
|
|
// filters. UIDs PhotoPrism can't resolve are returned in `errs` so the batch
|
|
// continues; a transport-level failure aborts with a fatal error. Mirrors
|
|
// handleRename's single-photo resolution.
|
|
func resolvePhotosFull(ctx context.Context, pp *ppClient, token string, uids []string) (photos []heapPhoto, errs []heapErr, err error) {
|
|
photos = make([]heapPhoto, 0, len(uids))
|
|
for _, uid := range uids {
|
|
resp, e := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil)
|
|
if e != nil {
|
|
return nil, nil, e
|
|
}
|
|
if !resp.OK {
|
|
errs = append(errs, heapErr{UID: uid, Reason: "photo not found"})
|
|
continue
|
|
}
|
|
var p heapPhoto
|
|
if e := json.Unmarshal(resp.Body, &p); e != nil {
|
|
return nil, nil, e
|
|
}
|
|
photos = append(photos, p)
|
|
}
|
|
return photos, errs, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
if !requireUserScope(c, cfg, oldAbs, true) {
|
|
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
|
|
}
|
|
if !requireUserScope(c, cfg, targetParentAbs, false) {
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
type restoreMovesBody struct {
|
|
// Moves mirror the movedFiles pairs from photos-move / heap-convert
|
|
// responses verbatim; the handler renames each `to` (current location)
|
|
// back to its `from` (original location).
|
|
Moves []dupMoved `json:"moves"`
|
|
}
|
|
|
|
// handleRestoreMoves is the generic inverse of movePhotoFiles: it moves
|
|
// files back to where they came from, powering ⌘Z undo for photo/heap
|
|
// moves. Unlike the duplicates restore (whose sources must live in the
|
|
// .duplicates/ quarantine), both ends here are arbitrary library paths —
|
|
// so BOTH are validated against the caller's scope, and existing
|
|
// destinations are never clobbered.
|
|
//
|
|
// Route: POST /api/sidecar/files/restore-moves (behind requireSession)
|
|
func handleRestoreMoves(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := ctxToken(c)
|
|
var body restoreMovesBody
|
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
|
|
return
|
|
}
|
|
|
|
scope := userScopeRoot(c, cfg)
|
|
type resolved struct {
|
|
srcAbs, dstAbs string
|
|
srcRel, dstRel string
|
|
}
|
|
items := make([]resolved, 0, len(body.Moves))
|
|
for _, m := range body.Moves {
|
|
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid source path: " + m.To})
|
|
return
|
|
}
|
|
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid destination path: " + m.From})
|
|
return
|
|
}
|
|
if !sameOrUnder(srcAbs, scope) || !sameOrUnder(dstAbs, scope) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
|
|
return
|
|
}
|
|
items = append(items, resolved{srcAbs: srcAbs, dstAbs: dstAbs, srcRel: m.To, dstRel: m.From})
|
|
}
|
|
|
|
restored := []dupMoved{}
|
|
errs := []dupArchiveErr{}
|
|
parents := map[string]struct{}{}
|
|
for _, it := range items {
|
|
if _, err := os.Stat(it.dstAbs); err == nil {
|
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "destination already exists"})
|
|
continue
|
|
} else if !os.IsNotExist(err) {
|
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(it.dstAbs), 0o755); err != nil {
|
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
|
|
continue
|
|
}
|
|
if err := os.Rename(it.srcAbs, it.dstAbs); err != nil {
|
|
if err2 := copyFile(it.srcAbs, it.dstAbs); err2 != nil {
|
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
|
|
continue
|
|
}
|
|
if err2 := os.Remove(it.srcAbs); err2 != nil {
|
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "restored but source remove failed: " + err2.Error()})
|
|
continue
|
|
}
|
|
}
|
|
restored = append(restored, dupMoved{From: it.srcRel, To: it.dstRel})
|
|
parents[filepath.Dir(it.srcRel)] = struct{}{}
|
|
parents[filepath.Dir(it.dstRel)] = struct{}{}
|
|
slog.Info("files.restore-moves", "from", it.srcRel, "to", it.dstRel)
|
|
}
|
|
|
|
// Block on the reindex like movePhotoFiles does — the client
|
|
// invalidates its photo queries right after this returns, and the
|
|
// refetch must already see the restored locations.
|
|
for p := range parents {
|
|
reindex := "/"
|
|
if p != "" && p != "." {
|
|
reindex = "/" + p
|
|
}
|
|
fireReindex(cfg, pp, token, reindex)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
|
|
}
|
|
}
|