From d10995302d99bb2f40894059f91ec56688484859 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:38:19 -0600 Subject: [PATCH] Feature: Add trash support (#6237) --- graphql/schema/types/config.graphql | 4 + internal/api/resolver_mutation_configure.go | 9 ++ internal/api/resolver_mutation_file.go | 4 +- internal/api/resolver_mutation_gallery.go | 4 +- internal/api/resolver_mutation_image.go | 8 +- internal/api/resolver_mutation_scene.go | 15 ++- internal/api/resolver_query_configuration.go | 1 + internal/manager/config/config.go | 11 +++ internal/manager/manager_tasks.go | 1 + pkg/file/clean.go | 7 +- pkg/file/delete.go | 97 ++++++++++++++++--- pkg/fsutil/trash.go | 43 ++++++++ pkg/image/delete.go | 3 +- pkg/scene/delete.go | 8 +- ui/v2.5/graphql/data/config.graphql | 1 + .../Galleries/DeleteGalleriesDialog.tsx | 7 +- .../components/Images/DeleteImagesDialog.tsx | 7 +- .../components/Scenes/DeleteScenesDialog.tsx | 7 +- .../Settings/SettingsSystemPanel.tsx | 8 ++ .../components/Shared/DeleteFilesDialog.tsx | 11 ++- ui/v2.5/src/locales/en-GB.json | 5 + 21 files changed, 226 insertions(+), 35 deletions(-) create mode 100644 pkg/fsutil/trash.go diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 6a1ac72be..732296572 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -69,6 +69,8 @@ input ConfigGeneralInput { databasePath: String "Path to backup directory" 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" generatedPath: String "Path to import/export files" @@ -191,6 +193,8 @@ type ConfigGeneralResult { databasePath: String! "Path to backup directory" 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" generatedPath: String! "Path to import/export files" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 3299c01a8..d49105916 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -150,6 +150,15 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen 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() if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath { if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil { diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index c303446e1..c5e5e3530 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -149,7 +149,9 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b return false, fmt.Errorf("converting ids: %w", err) } - fileDeleter := file.NewDeleter() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + + fileDeleter := file.NewDeleterWithTrash(trashPath) destroyer := &file.ZipDestroyer{ FileDestroyer: r.repository.File, FolderDestroyer: r.repository.Folder, diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 5d5cd4b37..db6862274 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -333,10 +333,12 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall return false, fmt.Errorf("converting ids: %w", err) } + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + var galleries []*models.Gallery var imgsDestroyed []*models.Image fileDeleter := &image.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 721598634..82d9be4cd 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -308,9 +308,11 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD return false, fmt.Errorf("converting id: %w", err) } + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + var i *models.Image fileDeleter := &image.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } 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) } + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + var images []*models.Image fileDeleter := &image.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } if err := r.withTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index b81ac0974..ae5903112 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -428,10 +428,11 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD } fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() var s *models.Scene fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } @@ -482,9 +483,10 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene var scenes []*models.Scene fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } @@ -593,8 +595,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput } mgr := manager.GetInstance() + trashPath := mgr.Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(), Paths: mgr.Paths, } @@ -736,9 +739,10 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar } mgr := manager.GetInstance() + trashPath := mgr.Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(), Paths: mgr.Paths, } @@ -949,9 +953,10 @@ func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs [] var markers []*models.SceneMarker fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 7213f8447..8a20fcad1 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -82,6 +82,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { Stashes: config.GetStashPaths(), DatabasePath: config.GetDatabasePath(), BackupDirectoryPath: config.GetBackupDirectoryPath(), + DeleteTrashPath: config.GetDeleteTrashPath(), GeneratedPath: config.GetGeneratedPath(), MetadataPath: config.GetMetadataPath(), ConfigFilePath: config.GetConfigFile(), diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index eda863663..c7b1c1fdf 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -272,6 +272,9 @@ const ( DeleteGeneratedDefault = "defaults.delete_generated" deleteGeneratedDefaultDefault = true + // Trash/Recycle Bin options + DeleteTrashPath = "delete_trash_path" + // Desktop Integration Options NoBrowser = "nobrowser" NoBrowserDefault = false @@ -1469,6 +1472,14 @@ func (i *Config) GetDeleteGeneratedDefault() bool { 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. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index b85a4c2cf..085c4459e 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -294,6 +294,7 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int { Handlers: []file.CleanHandler{ &cleanHandler{}, }, + TrashPath: s.Config.GetDeleteTrashPath(), } j := cleanJob{ diff --git a/pkg/file/clean.go b/pkg/file/clean.go index 8c54fd0e0..53b2e0612 100644 --- a/pkg/file/clean.go +++ b/pkg/file/clean.go @@ -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) diff --git a/pkg/file/delete.go b/pkg/file/delete.go index 88eb5169e..c36068faa 100644 --- a/pkg/file/delete.go +++ b/pkg/file/delete.go @@ -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) } diff --git a/pkg/fsutil/trash.go b/pkg/fsutil/trash.go new file mode 100644 index 000000000..9a3bed835 --- /dev/null +++ b/pkg/fsutil/trash.go @@ -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 +} diff --git a/pkg/image/delete.go b/pkg/image/delete.go index 69fba9bd6..aa3a9c1c8 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -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. diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go index 7426c390b..c34bbdf14 100644 --- a/pkg/scene/delete.go +++ b/pkg/scene/delete.go @@ -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 diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 192fb8053..ac3656efb 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -6,6 +6,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { } databasePath backupDirectoryPath + deleteTrashPath generatedPath metadataPath scrapersPath diff --git a/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx index 0e50c16b8..35aaea797 100644 --- a/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx @@ -84,6 +84,11 @@ export const DeleteGalleriesDialog: React.FC = ( return; } + const deleteTrashPath = config?.general.deleteTrashPath; + const deleteAlertId = deleteTrashPath + ? "dialogs.delete_alert_to_trash" + : "dialogs.delete_alert"; + return (

@@ -93,7 +98,7 @@ export const DeleteGalleriesDialog: React.FC = ( singularEntity: intl.formatMessage({ id: "file" }), pluralEntity: intl.formatMessage({ id: "files" }), }} - id="dialogs.delete_alert" + id={deleteAlertId} />