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