Feature: Add trash support (#6237)

This commit is contained in:
Gykes
2025-11-25 20:38:19 -06:00
committed by GitHub
parent d14053b570
commit d10995302d
21 changed files with 226 additions and 35 deletions

View File

@@ -18,7 +18,8 @@ type Cleaner struct {
FS models.FS
Repository Repository
Handlers []CleanHandler
Handlers []CleanHandler
TrashPath string
}
type cleanJob struct {
@@ -392,7 +393,7 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool
func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {
// delete associated objects
fileDeleter := NewDeleter()
fileDeleter := NewDeleterWithTrash(j.TrashPath)
r := j.Repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
fileDeleter.RegisterHooks(ctx)
@@ -410,7 +411,7 @@ func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn stri
func (j *cleanJob) deleteFolder(ctx context.Context, folderID models.FolderID, fn string) {
// delete associated objects
fileDeleter := NewDeleter()
fileDeleter := NewDeleterWithTrash(j.TrashPath)
r := j.Repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
fileDeleter.RegisterHooks(ctx)

View File

@@ -58,20 +58,33 @@ func newRenamerRemoverImpl() renamerRemoverImpl {
// Deleter is used to safely delete files and directories from the filesystem.
// During a transaction, files and directories are marked for deletion using
// the Files and Dirs methods. This will rename the files/directories to be
// deleted. If the transaction is rolled back, then the files/directories can
// be restored to their original state with the Abort method. If the
// transaction is committed, the marked files are then deleted from the
// filesystem using the Complete method.
// the Files and Dirs methods. If TrashPath is set, files are moved to trash
// immediately. Otherwise, they are renamed with a .delete suffix. If the
// transaction is rolled back, then the files/directories can be restored to
// their original state with the Rollback method. If the transaction is
// committed, the marked files are then deleted from the filesystem using the
// Commit method.
type Deleter struct {
RenamerRemover RenamerRemover
files []string
dirs []string
TrashPath string // if set, files will be moved to this directory instead of being permanently deleted
trashedPaths map[string]string // map of original path -> trash path (only used when TrashPath is set)
}
func NewDeleter() *Deleter {
return &Deleter{
RenamerRemover: newRenamerRemoverImpl(),
TrashPath: "",
trashedPaths: make(map[string]string),
}
}
func NewDeleterWithTrash(trashPath string) *Deleter {
return &Deleter{
RenamerRemover: newRenamerRemoverImpl(),
TrashPath: trashPath,
trashedPaths: make(map[string]string),
}
}
@@ -92,6 +105,17 @@ func (d *Deleter) RegisterHooks(ctx context.Context) {
// Abort should be called to restore marked files if this function returns an
// error.
func (d *Deleter) Files(paths []string) error {
return d.filesInternal(paths, false)
}
// FilesWithoutTrash designates files to be deleted, bypassing the trash directory.
// Files will be permanently deleted even if TrashPath is configured.
// This is useful for deleting generated files that can be easily recreated.
func (d *Deleter) FilesWithoutTrash(paths []string) error {
return d.filesInternal(paths, true)
}
func (d *Deleter) filesInternal(paths []string, bypassTrash bool) error {
for _, p := range paths {
// fail silently if the file does not exist
if _, err := d.RenamerRemover.Stat(p); err != nil {
@@ -103,7 +127,7 @@ func (d *Deleter) Files(paths []string) error {
return fmt.Errorf("check file %q exists: %w", p, err)
}
if err := d.renameForDelete(p); err != nil {
if err := d.renameForDelete(p, bypassTrash); err != nil {
return fmt.Errorf("marking file %q for deletion: %w", p, err)
}
d.files = append(d.files, p)
@@ -118,6 +142,17 @@ func (d *Deleter) Files(paths []string) error {
// Abort should be called to restore marked files/directories if this function returns an
// error.
func (d *Deleter) Dirs(paths []string) error {
return d.dirsInternal(paths, false)
}
// DirsWithoutTrash designates directories to be deleted, bypassing the trash directory.
// Directories will be permanently deleted even if TrashPath is configured.
// This is useful for deleting generated directories that can be easily recreated.
func (d *Deleter) DirsWithoutTrash(paths []string) error {
return d.dirsInternal(paths, true)
}
func (d *Deleter) dirsInternal(paths []string, bypassTrash bool) error {
for _, p := range paths {
// fail silently if the file does not exist
if _, err := d.RenamerRemover.Stat(p); err != nil {
@@ -129,7 +164,7 @@ func (d *Deleter) Dirs(paths []string) error {
return fmt.Errorf("check directory %q exists: %w", p, err)
}
if err := d.renameForDelete(p); err != nil {
if err := d.renameForDelete(p, bypassTrash); err != nil {
return fmt.Errorf("marking directory %q for deletion: %w", p, err)
}
d.dirs = append(d.dirs, p)
@@ -150,33 +185,65 @@ func (d *Deleter) Rollback() {
d.files = nil
d.dirs = nil
d.trashedPaths = make(map[string]string)
}
// Commit deletes all files marked for deletion and clears the marked list.
// When using trash, files have already been moved during renameForDelete, so
// this just clears the tracking. Otherwise, permanently delete the .delete files.
// Any errors encountered are logged. All files will be attempted, regardless
// of the errors encountered.
func (d *Deleter) Commit() {
for _, f := range d.files {
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
if d.TrashPath != "" {
// Files were already moved to trash during renameForDelete, just clear tracking
logger.Debugf("Commit: %d files and %d directories already in trash, clearing tracking", len(d.files), len(d.dirs))
} else {
// Permanently delete files and directories marked with .delete suffix
for _, f := range d.files {
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
}
}
}
for _, f := range d.dirs {
if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
for _, f := range d.dirs {
if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
}
}
}
d.files = nil
d.dirs = nil
d.trashedPaths = make(map[string]string)
}
func (d *Deleter) renameForDelete(path string) error {
func (d *Deleter) renameForDelete(path string, bypassTrash bool) error {
if d.TrashPath != "" && !bypassTrash {
// Move file to trash immediately
trashDest, err := fsutil.MoveToTrash(path, d.TrashPath)
if err != nil {
return err
}
d.trashedPaths[path] = trashDest
logger.Infof("Moved %q to trash at %s", path, trashDest)
return nil
}
// Standard behavior: rename with .delete suffix (or when bypassing trash)
return d.RenamerRemover.Rename(path, path+deleteFileSuffix)
}
func (d *Deleter) renameForRestore(path string) error {
if d.TrashPath != "" {
// Restore file from trash
trashPath, ok := d.trashedPaths[path]
if !ok {
return fmt.Errorf("no trash path found for %q", path)
}
return d.RenamerRemover.Rename(trashPath, path)
}
// Standard behavior: restore from .delete suffix
return d.RenamerRemover.Rename(path+deleteFileSuffix, path)
}

43
pkg/fsutil/trash.go Normal file
View File

@@ -0,0 +1,43 @@
package fsutil
import (
"fmt"
"os"
"path/filepath"
"time"
)
// MoveToTrash moves a file or directory to a custom trash directory.
// If a file with the same name already exists in the trash, a timestamp is appended.
// Returns the destination path where the file was moved to.
func MoveToTrash(sourcePath string, trashPath string) (string, error) {
// Get absolute path for the source
absSourcePath, err := filepath.Abs(sourcePath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
// Ensure trash directory exists
if err := os.MkdirAll(trashPath, 0755); err != nil {
return "", fmt.Errorf("failed to create trash directory: %w", err)
}
// Get the base name of the file/directory
baseName := filepath.Base(absSourcePath)
destPath := filepath.Join(trashPath, baseName)
// If a file with the same name already exists in trash, append timestamp
if _, err := os.Stat(destPath); err == nil {
ext := filepath.Ext(baseName)
nameWithoutExt := baseName[:len(baseName)-len(ext)]
timestamp := time.Now().Format("20060102-150405")
destPath = filepath.Join(trashPath, fmt.Sprintf("%s_%s%s", nameWithoutExt, timestamp, ext))
}
// Move the file to trash using SafeMove to support cross-filesystem moves
if err := SafeMove(absSourcePath, destPath); err != nil {
return "", fmt.Errorf("failed to move to trash: %w", err)
}
return destPath, nil
}

View File

@@ -19,6 +19,7 @@ type FileDeleter struct {
}
// MarkGeneratedFiles marks for deletion the generated files for the provided image.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
var files []string
thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth)
@@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
files = append(files, prevPath)
}
return d.Files(files)
return d.FilesWithoutTrash(files)
}
// Destroy destroys an image, optionally marking the file and generated files for deletion.

View File

@@ -21,6 +21,7 @@ type FileDeleter struct {
}
// MarkGeneratedFiles marks for deletion the generated files for the provided scene.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
sceneHash := scene.GetHash(d.FileNamingAlgo)
@@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
exists, _ := fsutil.FileExists(markersFolder)
if exists {
if err := d.Dirs([]string{markersFolder}); err != nil {
if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil {
return err
}
}
@@ -75,11 +76,12 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
files = append(files, heatmapPath)
}
return d.Files(files)
return d.FilesWithoutTrash(files)
}
// MarkMarkerFiles deletes generated files for a scene marker with the
// provided scene and timestamp.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
imagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
@@ -102,7 +104,7 @@ func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
files = append(files, screenshotPath)
}
return d.Files(files)
return d.FilesWithoutTrash(files)
}
// Destroy deletes a scene and its associated relationships from the