mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Feature: Add trash support (#6237)
This commit is contained in:
parent
d14053b570
commit
d10995302d
21 changed files with 226 additions and 35 deletions
|
|
@ -69,6 +69,8 @@ input ConfigGeneralInput {
|
||||||
databasePath: String
|
databasePath: String
|
||||||
"Path to backup directory"
|
"Path to backup directory"
|
||||||
backupDirectoryPath: String
|
backupDirectoryPath: String
|
||||||
|
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
|
||||||
|
deleteTrashPath: String
|
||||||
"Path to generated files"
|
"Path to generated files"
|
||||||
generatedPath: String
|
generatedPath: String
|
||||||
"Path to import/export files"
|
"Path to import/export files"
|
||||||
|
|
@ -191,6 +193,8 @@ type ConfigGeneralResult {
|
||||||
databasePath: String!
|
databasePath: String!
|
||||||
"Path to backup directory"
|
"Path to backup directory"
|
||||||
backupDirectoryPath: String!
|
backupDirectoryPath: String!
|
||||||
|
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
|
||||||
|
deleteTrashPath: String!
|
||||||
"Path to generated files"
|
"Path to generated files"
|
||||||
generatedPath: String!
|
generatedPath: String!
|
||||||
"Path to import/export files"
|
"Path to import/export files"
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,15 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
||||||
c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath)
|
c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingDeleteTrashPath := c.GetDeleteTrashPath()
|
||||||
|
if input.DeleteTrashPath != nil && existingDeleteTrashPath != *input.DeleteTrashPath {
|
||||||
|
if err := validateDir(config.DeleteTrashPath, *input.DeleteTrashPath, true); err != nil {
|
||||||
|
return makeConfigGeneralResult(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetString(config.DeleteTrashPath, *input.DeleteTrashPath)
|
||||||
|
}
|
||||||
|
|
||||||
existingGeneratedPath := c.GetGeneratedPath()
|
existingGeneratedPath := c.GetGeneratedPath()
|
||||||
if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath {
|
if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath {
|
||||||
if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil {
|
if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,9 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
|
||||||
return false, fmt.Errorf("converting ids: %w", err)
|
return false, fmt.Errorf("converting ids: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileDeleter := file.NewDeleter()
|
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
|
fileDeleter := file.NewDeleterWithTrash(trashPath)
|
||||||
destroyer := &file.ZipDestroyer{
|
destroyer := &file.ZipDestroyer{
|
||||||
FileDestroyer: r.repository.File,
|
FileDestroyer: r.repository.File,
|
||||||
FolderDestroyer: r.repository.Folder,
|
FolderDestroyer: r.repository.Folder,
|
||||||
|
|
|
||||||
|
|
@ -333,10 +333,12 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
|
||||||
return false, fmt.Errorf("converting ids: %w", err)
|
return false, fmt.Errorf("converting ids: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
var galleries []*models.Gallery
|
var galleries []*models.Gallery
|
||||||
var imgsDestroyed []*models.Image
|
var imgsDestroyed []*models.Image
|
||||||
fileDeleter := &image.FileDeleter{
|
fileDeleter := &image.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
Paths: manager.GetInstance().Paths,
|
Paths: manager.GetInstance().Paths,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -308,9 +308,11 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
|
||||||
return false, fmt.Errorf("converting id: %w", err)
|
return false, fmt.Errorf("converting id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
var i *models.Image
|
var i *models.Image
|
||||||
fileDeleter := &image.FileDeleter{
|
fileDeleter := &image.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
Paths: manager.GetInstance().Paths,
|
Paths: manager.GetInstance().Paths,
|
||||||
}
|
}
|
||||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||||
|
|
@ -348,9 +350,11 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
|
||||||
return false, fmt.Errorf("converting ids: %w", err)
|
return false, fmt.Errorf("converting ids: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
var images []*models.Image
|
var images []*models.Image
|
||||||
fileDeleter := &image.FileDeleter{
|
fileDeleter := &image.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
Paths: manager.GetInstance().Paths,
|
Paths: manager.GetInstance().Paths,
|
||||||
}
|
}
|
||||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||||
|
|
|
||||||
|
|
@ -428,10 +428,11 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
||||||
}
|
}
|
||||||
|
|
||||||
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
||||||
|
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
var s *models.Scene
|
var s *models.Scene
|
||||||
fileDeleter := &scene.FileDeleter{
|
fileDeleter := &scene.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
FileNamingAlgo: fileNamingAlgo,
|
FileNamingAlgo: fileNamingAlgo,
|
||||||
Paths: manager.GetInstance().Paths,
|
Paths: manager.GetInstance().Paths,
|
||||||
}
|
}
|
||||||
|
|
@ -482,9 +483,10 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
||||||
|
|
||||||
var scenes []*models.Scene
|
var scenes []*models.Scene
|
||||||
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
||||||
|
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
fileDeleter := &scene.FileDeleter{
|
fileDeleter := &scene.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
FileNamingAlgo: fileNamingAlgo,
|
FileNamingAlgo: fileNamingAlgo,
|
||||||
Paths: manager.GetInstance().Paths,
|
Paths: manager.GetInstance().Paths,
|
||||||
}
|
}
|
||||||
|
|
@ -593,8 +595,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||||
}
|
}
|
||||||
|
|
||||||
mgr := manager.GetInstance()
|
mgr := manager.GetInstance()
|
||||||
|
trashPath := mgr.Config.GetDeleteTrashPath()
|
||||||
fileDeleter := &scene.FileDeleter{
|
fileDeleter := &scene.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
||||||
Paths: mgr.Paths,
|
Paths: mgr.Paths,
|
||||||
}
|
}
|
||||||
|
|
@ -736,9 +739,10 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
|
||||||
}
|
}
|
||||||
|
|
||||||
mgr := manager.GetInstance()
|
mgr := manager.GetInstance()
|
||||||
|
trashPath := mgr.Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
fileDeleter := &scene.FileDeleter{
|
fileDeleter := &scene.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
|
||||||
Paths: mgr.Paths,
|
Paths: mgr.Paths,
|
||||||
}
|
}
|
||||||
|
|
@ -949,9 +953,10 @@ func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs []
|
||||||
|
|
||||||
var markers []*models.SceneMarker
|
var markers []*models.SceneMarker
|
||||||
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
|
||||||
|
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
|
||||||
|
|
||||||
fileDeleter := &scene.FileDeleter{
|
fileDeleter := &scene.FileDeleter{
|
||||||
Deleter: file.NewDeleter(),
|
Deleter: file.NewDeleterWithTrash(trashPath),
|
||||||
FileNamingAlgo: fileNamingAlgo,
|
FileNamingAlgo: fileNamingAlgo,
|
||||||
Paths: manager.GetInstance().Paths,
|
Paths: manager.GetInstance().Paths,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
|
||||||
Stashes: config.GetStashPaths(),
|
Stashes: config.GetStashPaths(),
|
||||||
DatabasePath: config.GetDatabasePath(),
|
DatabasePath: config.GetDatabasePath(),
|
||||||
BackupDirectoryPath: config.GetBackupDirectoryPath(),
|
BackupDirectoryPath: config.GetBackupDirectoryPath(),
|
||||||
|
DeleteTrashPath: config.GetDeleteTrashPath(),
|
||||||
GeneratedPath: config.GetGeneratedPath(),
|
GeneratedPath: config.GetGeneratedPath(),
|
||||||
MetadataPath: config.GetMetadataPath(),
|
MetadataPath: config.GetMetadataPath(),
|
||||||
ConfigFilePath: config.GetConfigFile(),
|
ConfigFilePath: config.GetConfigFile(),
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,9 @@ const (
|
||||||
DeleteGeneratedDefault = "defaults.delete_generated"
|
DeleteGeneratedDefault = "defaults.delete_generated"
|
||||||
deleteGeneratedDefaultDefault = true
|
deleteGeneratedDefaultDefault = true
|
||||||
|
|
||||||
|
// Trash/Recycle Bin options
|
||||||
|
DeleteTrashPath = "delete_trash_path"
|
||||||
|
|
||||||
// Desktop Integration Options
|
// Desktop Integration Options
|
||||||
NoBrowser = "nobrowser"
|
NoBrowser = "nobrowser"
|
||||||
NoBrowserDefault = false
|
NoBrowserDefault = false
|
||||||
|
|
@ -1469,6 +1472,14 @@ func (i *Config) GetDeleteGeneratedDefault() bool {
|
||||||
return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)
|
return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *Config) GetDeleteTrashPath() string {
|
||||||
|
return i.getString(DeleteTrashPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Config) SetDeleteTrashPath(value string) {
|
||||||
|
i.SetString(DeleteTrashPath, value)
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultIdentifySettings returns the default Identify task settings.
|
// GetDefaultIdentifySettings returns the default Identify task settings.
|
||||||
// Returns nil if the settings could not be unmarshalled, or if it
|
// Returns nil if the settings could not be unmarshalled, or if it
|
||||||
// has not been set.
|
// has not been set.
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,7 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
|
||||||
Handlers: []file.CleanHandler{
|
Handlers: []file.CleanHandler{
|
||||||
&cleanHandler{},
|
&cleanHandler{},
|
||||||
},
|
},
|
||||||
|
TrashPath: s.Config.GetDeleteTrashPath(),
|
||||||
}
|
}
|
||||||
|
|
||||||
j := cleanJob{
|
j := cleanJob{
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type Cleaner struct {
|
||||||
Repository Repository
|
Repository Repository
|
||||||
|
|
||||||
Handlers []CleanHandler
|
Handlers []CleanHandler
|
||||||
|
TrashPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type cleanJob struct {
|
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) {
|
func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {
|
||||||
// delete associated objects
|
// delete associated objects
|
||||||
fileDeleter := NewDeleter()
|
fileDeleter := NewDeleterWithTrash(j.TrashPath)
|
||||||
r := j.Repository
|
r := j.Repository
|
||||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||||
fileDeleter.RegisterHooks(ctx)
|
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) {
|
func (j *cleanJob) deleteFolder(ctx context.Context, folderID models.FolderID, fn string) {
|
||||||
// delete associated objects
|
// delete associated objects
|
||||||
fileDeleter := NewDeleter()
|
fileDeleter := NewDeleterWithTrash(j.TrashPath)
|
||||||
r := j.Repository
|
r := j.Repository
|
||||||
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
if err := r.WithTxn(ctx, func(ctx context.Context) error {
|
||||||
fileDeleter.RegisterHooks(ctx)
|
fileDeleter.RegisterHooks(ctx)
|
||||||
|
|
|
||||||
|
|
@ -58,20 +58,33 @@ func newRenamerRemoverImpl() renamerRemoverImpl {
|
||||||
|
|
||||||
// Deleter is used to safely delete files and directories from the filesystem.
|
// Deleter is used to safely delete files and directories from the filesystem.
|
||||||
// During a transaction, files and directories are marked for deletion using
|
// During a transaction, files and directories are marked for deletion using
|
||||||
// the Files and Dirs methods. This will rename the files/directories to be
|
// the Files and Dirs methods. If TrashPath is set, files are moved to trash
|
||||||
// deleted. If the transaction is rolled back, then the files/directories can
|
// immediately. Otherwise, they are renamed with a .delete suffix. If the
|
||||||
// be restored to their original state with the Abort method. If the
|
// transaction is rolled back, then the files/directories can be restored to
|
||||||
// transaction is committed, the marked files are then deleted from the
|
// their original state with the Rollback method. If the transaction is
|
||||||
// filesystem using the Complete method.
|
// committed, the marked files are then deleted from the filesystem using the
|
||||||
|
// Commit method.
|
||||||
type Deleter struct {
|
type Deleter struct {
|
||||||
RenamerRemover RenamerRemover
|
RenamerRemover RenamerRemover
|
||||||
files []string
|
files []string
|
||||||
dirs []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 {
|
func NewDeleter() *Deleter {
|
||||||
return &Deleter{
|
return &Deleter{
|
||||||
RenamerRemover: newRenamerRemoverImpl(),
|
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
|
// Abort should be called to restore marked files if this function returns an
|
||||||
// error.
|
// error.
|
||||||
func (d *Deleter) Files(paths []string) 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 {
|
for _, p := range paths {
|
||||||
// fail silently if the file does not exist
|
// fail silently if the file does not exist
|
||||||
if _, err := d.RenamerRemover.Stat(p); err != nil {
|
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)
|
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)
|
return fmt.Errorf("marking file %q for deletion: %w", p, err)
|
||||||
}
|
}
|
||||||
d.files = append(d.files, p)
|
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
|
// Abort should be called to restore marked files/directories if this function returns an
|
||||||
// error.
|
// error.
|
||||||
func (d *Deleter) Dirs(paths []string) 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 {
|
for _, p := range paths {
|
||||||
// fail silently if the file does not exist
|
// fail silently if the file does not exist
|
||||||
if _, err := d.RenamerRemover.Stat(p); err != nil {
|
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)
|
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)
|
return fmt.Errorf("marking directory %q for deletion: %w", p, err)
|
||||||
}
|
}
|
||||||
d.dirs = append(d.dirs, p)
|
d.dirs = append(d.dirs, p)
|
||||||
|
|
@ -150,12 +185,20 @@ func (d *Deleter) Rollback() {
|
||||||
|
|
||||||
d.files = nil
|
d.files = nil
|
||||||
d.dirs = nil
|
d.dirs = nil
|
||||||
|
d.trashedPaths = make(map[string]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit deletes all files marked for deletion and clears the marked list.
|
// 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
|
// Any errors encountered are logged. All files will be attempted, regardless
|
||||||
// of the errors encountered.
|
// of the errors encountered.
|
||||||
func (d *Deleter) Commit() {
|
func (d *Deleter) Commit() {
|
||||||
|
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 {
|
for _, f := range d.files {
|
||||||
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
|
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
|
||||||
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
|
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
|
||||||
|
|
@ -167,16 +210,40 @@ func (d *Deleter) Commit() {
|
||||||
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
|
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
d.files = nil
|
d.files = nil
|
||||||
d.dirs = 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)
|
return d.RenamerRemover.Rename(path, path+deleteFileSuffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Deleter) renameForRestore(path string) error {
|
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)
|
return d.RenamerRemover.Rename(path+deleteFileSuffix, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
pkg/fsutil/trash.go
Normal file
43
pkg/fsutil/trash.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ type FileDeleter struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkGeneratedFiles marks for deletion the generated files for the provided image.
|
// 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 {
|
func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
|
||||||
var files []string
|
var files []string
|
||||||
thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth)
|
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)
|
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.
|
// Destroy destroys an image, optionally marking the file and generated files for deletion.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type FileDeleter struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkGeneratedFiles marks for deletion the generated files for the provided scene.
|
// 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 {
|
func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
|
||||||
sceneHash := scene.GetHash(d.FileNamingAlgo)
|
sceneHash := scene.GetHash(d.FileNamingAlgo)
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
|
||||||
|
|
||||||
exists, _ := fsutil.FileExists(markersFolder)
|
exists, _ := fsutil.FileExists(markersFolder)
|
||||||
if exists {
|
if exists {
|
||||||
if err := d.Dirs([]string{markersFolder}); err != nil {
|
if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,11 +76,12 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
|
||||||
files = append(files, heatmapPath)
|
files = append(files, heatmapPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return d.Files(files)
|
return d.FilesWithoutTrash(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkMarkerFiles deletes generated files for a scene marker with the
|
// MarkMarkerFiles deletes generated files for a scene marker with the
|
||||||
// provided scene and timestamp.
|
// 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 {
|
func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
|
||||||
videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
|
videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
|
||||||
imagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(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)
|
files = append(files, screenshotPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return d.Files(files)
|
return d.FilesWithoutTrash(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destroy deletes a scene and its associated relationships from the
|
// Destroy deletes a scene and its associated relationships from the
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
||||||
}
|
}
|
||||||
databasePath
|
databasePath
|
||||||
backupDirectoryPath
|
backupDirectoryPath
|
||||||
|
deleteTrashPath
|
||||||
generatedPath
|
generatedPath
|
||||||
metadataPath
|
metadataPath
|
||||||
scrapersPath
|
scrapersPath
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteTrashPath = config?.general.deleteTrashPath;
|
||||||
|
const deleteAlertId = deleteTrashPath
|
||||||
|
? "dialogs.delete_alert_to_trash"
|
||||||
|
: "dialogs.delete_alert";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="delete-dialog alert alert-danger text-break">
|
<div className="delete-dialog alert alert-danger text-break">
|
||||||
<p className="font-weight-bold">
|
<p className="font-weight-bold">
|
||||||
|
|
@ -93,7 +98,7 @@ export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (
|
||||||
singularEntity: intl.formatMessage({ id: "file" }),
|
singularEntity: intl.formatMessage({ id: "file" }),
|
||||||
pluralEntity: intl.formatMessage({ id: "files" }),
|
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||||
}}
|
}}
|
||||||
id="dialogs.delete_alert"
|
id={deleteAlertId}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,11 @@ export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (
|
||||||
deletedFiles.push(...paths);
|
deletedFiles.push(...paths);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteTrashPath = config?.general.deleteTrashPath;
|
||||||
|
const deleteAlertId = deleteTrashPath
|
||||||
|
? "dialogs.delete_alert_to_trash"
|
||||||
|
: "dialogs.delete_alert";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="delete-dialog alert alert-danger text-break">
|
<div className="delete-dialog alert alert-danger text-break">
|
||||||
<p className="font-weight-bold">
|
<p className="font-weight-bold">
|
||||||
|
|
@ -89,7 +94,7 @@ export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (
|
||||||
singularEntity: intl.formatMessage({ id: "file" }),
|
singularEntity: intl.formatMessage({ id: "file" }),
|
||||||
pluralEntity: intl.formatMessage({ id: "files" }),
|
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||||
}}
|
}}
|
||||||
id="dialogs.delete_alert"
|
id={deleteAlertId}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,11 @@ export const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = (
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteTrashPath = config?.general.deleteTrashPath;
|
||||||
|
const deleteAlertId = deleteTrashPath
|
||||||
|
? "dialogs.delete_alert_to_trash"
|
||||||
|
: "dialogs.delete_alert";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="delete-dialog alert alert-danger text-break">
|
<div className="delete-dialog alert alert-danger text-break">
|
||||||
<p className="font-weight-bold">
|
<p className="font-weight-bold">
|
||||||
|
|
@ -103,7 +108,7 @@ export const DeleteScenesDialog: React.FC<IDeleteSceneDialogProps> = (
|
||||||
singularEntity: intl.formatMessage({ id: "file" }),
|
singularEntity: intl.formatMessage({ id: "file" }),
|
||||||
pluralEntity: intl.formatMessage({ id: "files" }),
|
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||||
}}
|
}}
|
||||||
id="dialogs.delete_alert"
|
id={deleteAlertId}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||||
value={general.backupDirectoryPath ?? undefined}
|
value={general.backupDirectoryPath ?? undefined}
|
||||||
onChange={(v) => saveGeneral({ backupDirectoryPath: v })}
|
onChange={(v) => saveGeneral({ backupDirectoryPath: v })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StringSetting
|
||||||
|
id="delete-trash-path"
|
||||||
|
headingID="config.general.delete_trash_path.heading"
|
||||||
|
subHeadingID="config.general.delete_trash_path.description"
|
||||||
|
value={general.deleteTrashPath ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ deleteTrashPath: v })}
|
||||||
|
/>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
<SettingSection headingID="config.general.database">
|
<SettingSection headingID="config.general.database">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||||
import { mutateDeleteFiles } from "src/core/StashService";
|
import { mutateDeleteFiles } from "src/core/StashService";
|
||||||
import { ModalComponent } from "./Modal";
|
import { ModalComponent } from "./Modal";
|
||||||
import { useToast } from "src/hooks/Toast";
|
import { useToast } from "src/hooks/Toast";
|
||||||
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
|
@ -40,6 +41,9 @@ export const DeleteFilesDialog: React.FC<IDeleteSceneDialogProps> = (
|
||||||
// Network state
|
// Network state
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const context = React.useContext(ConfigurationContext);
|
||||||
|
const config = context?.configuration;
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -56,6 +60,11 @@ export const DeleteFilesDialog: React.FC<IDeleteSceneDialogProps> = (
|
||||||
function renderDeleteFileAlert() {
|
function renderDeleteFileAlert() {
|
||||||
const deletedFiles = props.selected.map((f) => f.path);
|
const deletedFiles = props.selected.map((f) => f.path);
|
||||||
|
|
||||||
|
const deleteTrashPath = config?.general.deleteTrashPath;
|
||||||
|
const deleteAlertId = deleteTrashPath
|
||||||
|
? "dialogs.delete_alert_to_trash"
|
||||||
|
: "dialogs.delete_alert";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="delete-dialog alert alert-danger text-break">
|
<div className="delete-dialog alert alert-danger text-break">
|
||||||
<p className="font-weight-bold">
|
<p className="font-weight-bold">
|
||||||
|
|
@ -65,7 +74,7 @@ export const DeleteFilesDialog: React.FC<IDeleteSceneDialogProps> = (
|
||||||
singularEntity: intl.formatMessage({ id: "file" }),
|
singularEntity: intl.formatMessage({ id: "file" }),
|
||||||
pluralEntity: intl.formatMessage({ id: "files" }),
|
pluralEntity: intl.formatMessage({ id: "files" }),
|
||||||
}}
|
}}
|
||||||
id="dialogs.delete_alert"
|
id={deleteAlertId}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,10 @@
|
||||||
"description": "Directory location for SQLite database file backups",
|
"description": "Directory location for SQLite database file backups",
|
||||||
"heading": "Backup Directory Path"
|
"heading": "Backup Directory Path"
|
||||||
},
|
},
|
||||||
|
"delete_trash_path": {
|
||||||
|
"description": "Path where deleted files will be moved to instead of being permanently deleted. Leave empty to permanently delete files.",
|
||||||
|
"heading": "Trash Path"
|
||||||
|
},
|
||||||
"blobs_path": {
|
"blobs_path": {
|
||||||
"description": "Where in the filesystem to store binary data. Applicable only when using the Filesystem blob storage type. WARNING: changing this requires manually moving existing data.",
|
"description": "Where in the filesystem to store binary data. Applicable only when using the Filesystem blob storage type. WARNING: changing this requires manually moving existing data.",
|
||||||
"heading": "Binary data filesystem path"
|
"heading": "Binary data filesystem path"
|
||||||
|
|
@ -907,6 +911,7 @@
|
||||||
"clear_play_history_confirm": "Are you sure you want to clear the play history?",
|
"clear_play_history_confirm": "Are you sure you want to clear the play history?",
|
||||||
"create_new_entity": "Create new {entity}",
|
"create_new_entity": "Create new {entity}",
|
||||||
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
|
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
|
||||||
|
"delete_alert_to_trash": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be moved to trash:",
|
||||||
"delete_confirm": "Are you sure you want to delete {entityName}?",
|
"delete_confirm": "Are you sure you want to delete {entityName}?",
|
||||||
"delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}",
|
"delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}",
|
||||||
"delete_entity_simple_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}?} other {Are you sure you want to delete these {pluralEntity}?}}",
|
"delete_entity_simple_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}?} other {Are you sure you want to delete these {pluralEntity}?}}",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue