diff --git a/sidecar/handlers_heap.go b/sidecar/handlers_heap.go index cd2ee97..b1b0fe1 100644 --- a/sidecar/handlers_heap.go +++ b/sidecar/handlers_heap.go @@ -127,7 +127,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { return } - moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg)) + 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 @@ -157,6 +157,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{ "moved": moved, "copied": copied, + "movedFiles": movedPairs, "errors": errs, "heap_deleted": heapDeleted, }) @@ -173,17 +174,23 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { // `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. -func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, errs []heapErr, err error) { +// +// `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, e + 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 @@ -282,6 +289,9 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, 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() @@ -329,7 +339,7 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, fireReindex(cfg, pp, token, reindex) } - return moved, copied, errs, nil + return moved, copied, movedPairs, errs, nil } // resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"." diff --git a/sidecar/handlers_move.go b/sidecar/handlers_move.go index b72bfe1..4942d46 100644 --- a/sidecar/handlers_move.go +++ b/sidecar/handlers_move.go @@ -70,7 +70,7 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc { return } - moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg)) + 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 @@ -87,9 +87,10 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc { "errors", len(errs), ) c.JSON(http.StatusOK, gin.H{ - "moved": moved, - "copied": copied, - "errors": errs, + "moved": moved, + "copied": copied, + "movedFiles": movedPairs, + "errors": errs, }) } } @@ -203,3 +204,96 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc { }) } } + +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}) + } +} diff --git a/sidecar/main.go b/sidecar/main.go index 1f9df69..1d2f0ec 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -99,6 +99,7 @@ func main() { auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/photos/move", handlePhotosMove(cfg, pp)) + auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp)) auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db)) auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db)) diff --git a/web/src/lib/components/layout/FolderTree.svelte b/web/src/lib/components/layout/FolderTree.svelte index 2bd2306..15e05a0 100644 --- a/web/src/lib/components/layout/FolderTree.svelte +++ b/web/src/lib/components/layout/FolderTree.svelte @@ -69,6 +69,10 @@ * "{n} photos" affordance. Undefined keeps the badge off entirely * (the picker dialog doesn't need it). */ counts?: Record; + /** Render every branch expanded regardless of the persisted openSet — + * the picker turns this on while a search filter is active so matches + * buried in collapsed branches stay visible. */ + forceExpand?: boolean; } let { nodes, @@ -80,7 +84,8 @@ onMove, readonly = false, selectedPath, - counts + counts, + forceExpand = false }: Props = $props(); // Auto-expanded folders, persisted to localStorage so the tree state @@ -145,7 +150,7 @@