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>
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type heapConvertBody struct {
|
|
TargetFolder string `json:"targetFolder"`
|
|
Mode string `json:"mode"` // "move" or "copy"
|
|
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
|
DeleteHeap bool `json:"deleteHeap"`
|
|
}
|
|
|
|
type heapPhoto struct {
|
|
UID string `json:"UID"`
|
|
Files []ppFile `json:"Files"`
|
|
}
|
|
|
|
type heapErr struct {
|
|
UID string `json:"uid"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// copyFile is the os.Rename fallback for cross-device moves and the
|
|
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
|
|
// memory; preserves mode bits, sets the modification time to now (we're
|
|
// creating a new inode either way).
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
st, err := in.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := ctxToken(c)
|
|
albumUID := c.Param("uid")
|
|
|
|
var body heapConvertBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
return
|
|
}
|
|
mode := body.Mode
|
|
if mode != "copy" {
|
|
mode = "move"
|
|
}
|
|
deleteHeap := mode == "move" && body.DeleteHeap
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if !requireUserScope(c, cfg, targetAbs, false) {
|
|
return
|
|
}
|
|
// Pull the heap's membership via the q=album:UID query (count=1000
|
|
// covers every realistic heap). We only need the UID list here — the
|
|
// search's Files array is trimmed and drops videos, so we re-resolve
|
|
// each photo's full file set below via resolvePhotosFull.
|
|
q := url.QueryEscape("album:" + albumUID)
|
|
listURL := "/api/v1/photos?q=" + q + "&count=1000&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 listed []heapPhoto
|
|
if err := json.Unmarshal(resp.Body, &listed); err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
|
return
|
|
}
|
|
uids := make([]string, 0, len(listed))
|
|
for _, p := range listed {
|
|
uids = append(uids, p.UID)
|
|
}
|
|
|
|
// Re-fetch each photo's complete file list so videos (and other multi-
|
|
// file photos) move whole — the album search alone would orphan the
|
|
// .mov. See resolvePhotosFull.
|
|
photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, 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
|
|
}
|
|
errs = append(resolveErrs, errs...)
|
|
|
|
heapDeleted := false
|
|
if deleteHeap {
|
|
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
|
|
if err == nil && r.OK {
|
|
heapDeleted = true
|
|
} else if err != nil {
|
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
|
|
} else {
|
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
|
|
}
|
|
}
|
|
|
|
slog.Info("heap.convert",
|
|
"album", albumUID,
|
|
"mode", mode,
|
|
"moved", moved,
|
|
"copied", copied,
|
|
"errors", len(errs),
|
|
"heap_deleted", heapDeleted,
|
|
)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"moved": moved,
|
|
"copied": copied,
|
|
"movedFiles": movedPairs,
|
|
"errors": errs,
|
|
"heap_deleted": heapDeleted,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
// `scopeAbs` is the caller's userScopeRoot — source files outside it fail
|
|
// per-photo, so a UID that resolves outside the user's BasePath (however
|
|
// PhotoPrism came to return it) can't be used to pull files across users.
|
|
//
|
|
// `movedPairs` records every file that physically moved (move mode only —
|
|
// copies have no inverse pair) as originals-relative {from,to}, including
|
|
// siblings of photos that later failed partway: undo must restore whatever
|
|
// actually left its folder, not just fully-successful photos.
|
|
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, movedPairs []dupMoved, 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, nil, e
|
|
}
|
|
}
|
|
|
|
sourceParents := map[string]struct{}{}
|
|
errs = []heapErr{}
|
|
movedPairs = []dupMoved{}
|
|
|
|
for _, photo := range photos {
|
|
// Gather *every* originals-rooted file of the photo, not just the
|
|
// primary. A video, Live Photo, or RAW+JPG pair keeps several files
|
|
// under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
|
|
// must travel together — moving only the primary orphans the rest, so
|
|
// the photo looks "moved" in PhotoPrism (the poster defines its path)
|
|
// while the actual video is left behind and silently breaks. Sidecar-
|
|
// rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
|
|
// on reindex and intentionally skipped. Pick the stem from the primary
|
|
// (or the first originals file) so the siblings re-stack under one name.
|
|
var group []ppFile
|
|
var primary ppFile
|
|
havePrimary := false
|
|
for _, f := range photo.Files {
|
|
if f.Root != "/" {
|
|
continue
|
|
}
|
|
group = append(group, f)
|
|
if f.Primary && !havePrimary {
|
|
primary, havePrimary = f, true
|
|
}
|
|
}
|
|
if len(group) == 0 {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
|
continue
|
|
}
|
|
if !havePrimary {
|
|
primary = group[0]
|
|
}
|
|
|
|
// Choose one collision-free stem for the whole group up front, so the
|
|
// siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
|
|
exts := make([]string, 0, len(group))
|
|
extSeen := map[string]struct{}{}
|
|
for _, f := range group {
|
|
ext := filepath.Ext(f.Name)
|
|
if _, dup := extSeen[ext]; !dup {
|
|
extSeen[ext] = struct{}{}
|
|
exts = append(exts, ext)
|
|
}
|
|
}
|
|
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
|
|
if !ok {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
|
continue
|
|
}
|
|
|
|
// Move/copy each sibling. A failure on any one fails the whole photo
|
|
// (surfaced in errs) rather than leaving a half-moved stack unreported.
|
|
var failure string
|
|
movedAny := false
|
|
usedNames := map[string]struct{}{}
|
|
for _, f := range group {
|
|
srcRel := f.Name
|
|
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
|
if !sameOrUnder(srcAbs, scopeAbs) {
|
|
failure = "path outside your library"
|
|
break
|
|
}
|
|
st, statErr := os.Stat(srcAbs)
|
|
if statErr != nil || !st.Mode().IsRegular() {
|
|
failure = "file missing on disk"
|
|
break
|
|
}
|
|
if filepath.Dir(srcAbs) == destAbs {
|
|
// Already in the target folder — nothing to do for this sibling,
|
|
// but the photo isn't an error just because one file is in place.
|
|
continue
|
|
}
|
|
name := stem + filepath.Ext(srcAbs)
|
|
// Two originals files sharing an extension (rare) would collide on
|
|
// the shared stem; keep the extra one's own unique name so neither
|
|
// overwrites the other.
|
|
if _, clash := usedNames[name]; clash {
|
|
_, n, uok := uniqueName(destAbs, filepath.Base(srcAbs))
|
|
if !uok {
|
|
failure = "too many collisions"
|
|
break
|
|
}
|
|
name = n
|
|
}
|
|
usedNames[name] = struct{}{}
|
|
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 {
|
|
failure = mvErr.Error()
|
|
break
|
|
}
|
|
if err2 := os.Remove(srcAbs); err2 != nil {
|
|
failure = "rename ok, source remove failed: " + err2.Error()
|
|
break
|
|
}
|
|
}
|
|
if dstRel, relErr := filepath.Rel(cfg.OriginalsRoot, dstAbs); relErr == nil {
|
|
movedPairs = append(movedPairs, dupMoved{From: srcRel, To: dstRel})
|
|
}
|
|
} else {
|
|
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
|
failure = cpErr.Error()
|
|
break
|
|
}
|
|
}
|
|
movedAny = true
|
|
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
|
}
|
|
if failure != "" {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
|
|
continue
|
|
}
|
|
if !movedAny {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
|
continue
|
|
}
|
|
if mode == "move" {
|
|
moved++
|
|
} else {
|
|
copied++
|
|
}
|
|
}
|
|
|
|
// 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, movedPairs, 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)
|
|
}
|