Files
mule-image/sidecar/handlers_heap.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

277 lines
8.4 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
}
// 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.
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 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
}
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,
"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).
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)
}