diff --git a/requirements.txt b/requirements.txt
index 9eb290390..ff6e6d4ab 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,6 +12,7 @@ PySide6_Addons==6.8.0.1
PySide6_Essentials==6.8.0.1
PySide6==6.8.0.1
rawpy==0.22.0
+Send2Trash==1.8.3
SQLAlchemy==2.0.34
structlog==24.4.0
typing_extensions>=3.10.0.0,<=4.11.0
diff --git a/tagstudio/resources/qt/videos/placeholder.mp4 b/tagstudio/resources/qt/videos/placeholder.mp4
new file mode 100644
index 000000000..1e22e4c72
Binary files /dev/null and b/tagstudio/resources/qt/videos/placeholder.mp4 differ
diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json
index 4776c1a01..7608d3cae 100644
--- a/tagstudio/resources/translations/en.json
+++ b/tagstudio/resources/translations/en.json
@@ -157,6 +157,9 @@
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
"macros.running.dialog.title": "Running Macros on New Entries",
"media_player.autoplay": "Autoplay",
+ "menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}",
+ "menu.delete_selected_files_plural": "Move Files to {trash_term}",
+ "menu.delete_selected_files_singular": "Move File to {trash_term}",
"menu.edit.ignore_list": "Ignore Files and Folders",
"menu.edit.manage_file_extensions": "Manage File Extensions",
"menu.edit.manage_tags": "Manage Tags",
@@ -195,6 +198,11 @@
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"splash.opening_library": "Opening Library \"{library_path}\"...",
+ "status.deleted_file_plural": "Deleted {count} files!",
+ "status.deleted_file_singular": "Deleted 1 file!",
+ "status.deleted_none": "No files deleted.",
+ "status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
+ "status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Saving Library Backup...",
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
"status.library_closed": "Library Closed ({time_span})",
@@ -230,6 +238,18 @@
"tag.shorthand": "Shorthand",
"tag.tag_name_required": "Tag Name (Required)",
"tag.view_limit": "View Limit:",
+ "trash.context.ambiguous": "Move file(s) to {trash_term}",
+ "trash.context.plural": "Move files to {trash_term}",
+ "trash.context.singular": "Move file to {trash_term}",
+ "trash.dialog.disambiguation_warning.plural": "This will remove them from TagStudio AND your file system!",
+ "trash.dialog.disambiguation_warning.singular": "This will remove it from TagStudio AND your file system!",
+ "trash.dialog.move.confirmation.plural": "Are you sure you want to move these {count} files to the {trash_term}?",
+ "trash.dialog.move.confirmation.singular": "Are you sure you want to move this file to the {trash_term}?",
+ "trash.dialog.permanent_delete_warning": "WARNING! If this file can't be moved to the {trash_term}, it will be permanently deleted!",
+ "trash.dialog.title.plural": "Delete Files",
+ "trash.dialog.title.singular": "Delete File",
+ "trash.name.generic": "Trash",
+ "trash.name.windows": "Recycle Bin",
"view.size.0": "Mini",
"view.size.1": "Small",
"view.size.2": "Medium",
diff --git a/tagstudio/src/qt/helpers/file_deleter.py b/tagstudio/src/qt/helpers/file_deleter.py
new file mode 100644
index 000000000..a8e50e17c
--- /dev/null
+++ b/tagstudio/src/qt/helpers/file_deleter.py
@@ -0,0 +1,30 @@
+# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
+# Licensed under the GPL-3.0 License.
+# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
+
+import logging
+from pathlib import Path
+
+from send2trash import send2trash
+
+logging.basicConfig(format="%(message)s", level=logging.INFO)
+
+
+def delete_file(path: str | Path) -> bool:
+ """Send a file to the system trash.
+
+ Args:
+ path (str | Path): The path of the file to delete.
+ """
+ _path = Path(path)
+ try:
+ logging.info(f"[delete_file] Sending to Trash: {_path}")
+ send2trash(_path)
+ return True
+ except PermissionError as e:
+ logging.error(f"[delete_file][ERROR] PermissionError: {e}")
+ except FileNotFoundError:
+ logging.error(f"[delete_file][ERROR] File Not Found: {_path}")
+ except Exception as e:
+ logging.error(e)
+ return False
diff --git a/tagstudio/src/qt/platform_strings.py b/tagstudio/src/qt/platform_strings.py
index 23851dd48..70426b12c 100644
--- a/tagstudio/src/qt/platform_strings.py
+++ b/tagstudio/src/qt/platform_strings.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
+# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -9,10 +9,17 @@
from src.qt.translations import Translations
-class PlatformStrings:
- open_file_str: str = Translations["file.open_location.generic"]
-
+def open_file_str() -> str:
if platform.system() == "Windows":
- open_file_str = Translations["file.open_location.windows"]
+ return Translations["file.open_location.windows"]
elif platform.system() == "Darwin":
- open_file_str = Translations["file.open_location.mac"]
+ return Translations["file.open_location.mac"]
+ else:
+ return Translations["file.open_location.generic"]
+
+
+def trash_term() -> str:
+ if platform.system() == "Windows":
+ return Translations["trash.name.windows"]
+ else:
+ return Translations["trash.name.generic"]
diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py
index 9c732b1cf..3e3c83904 100644
--- a/tagstudio/src/qt/ts_qt.py
+++ b/tagstudio/src/qt/ts_qt.py
@@ -7,6 +7,7 @@
"""A Qt driver for TagStudio."""
+import contextlib
import ctypes
import dataclasses
import math
@@ -67,6 +68,7 @@
from src.core.library.alchemy.fields import _FieldID
from src.core.library.alchemy.library import Entry, LibraryStatus
from src.core.media_types import MediaCategories
+from src.core.palette import ColorType, UiColor, get_ui_color
from src.core.query_lang.util import ParsingError
from src.core.ts_core import TagStudioCore
from src.core.utils.refresh_dir import RefreshDirTracker
@@ -74,6 +76,7 @@
from src.qt.cache_manager import CacheManager
from src.qt.flowlayout import FlowLayout
from src.qt.helpers.custom_runnable import CustomRunnable
+from src.qt.helpers.file_deleter import delete_file
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.main_window import Ui_MainWindow
from src.qt.modals.about import AboutModal
@@ -86,6 +89,7 @@
from src.qt.modals.folders_to_tags import FoldersToTagsModal
from src.qt.modals.tag_database import TagDatabasePanel
from src.qt.modals.tag_search import TagSearchPanel
+from src.qt.platform_strings import trash_term
from src.qt.resource_manager import ResourceManager
from src.qt.splash import Splash
from src.qt.translations import Translations
@@ -498,6 +502,17 @@ def start(self) -> None:
edit_menu.addSeparator()
+ self.delete_file_action = QAction(menu_bar)
+ Translations.translate_qobject(
+ self.delete_file_action, "menu.delete_selected_files_ambiguous", trash_term=trash_term()
+ )
+ self.delete_file_action.triggered.connect(lambda f="": self.delete_files_callback(f))
+ self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
+ self.delete_file_action.setEnabled(False)
+ edit_menu.addAction(self.delete_file_action)
+
+ edit_menu.addSeparator()
+
self.manage_file_ext_action = QAction(menu_bar)
Translations.translate_qobject(
self.manage_file_ext_action, "menu.edit.manage_file_extensions"
@@ -839,10 +854,13 @@ def close_library(self, is_shutdown: bool = False):
self.main_window.setWindowTitle(self.base_title)
- self.selected = []
- self.frame_content = []
+ self.selected.clear()
+ self.frame_content.clear()
[x.set_mode(None) for x in self.item_thumbs]
+ self.set_clipboard_menu_viability()
+ self.set_select_actions_visibility()
+
self.preview_panel.update_widgets()
self.main_window.toggle_landing_page(enabled=True)
self.main_window.pagination.setHidden(True)
@@ -937,6 +955,141 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]):
for entry_id in self.selected:
self.lib.add_tags_to_entry(entry_id, tag_ids)
+ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None):
+ """Callback to send on or more files to the system trash.
+
+ If 0-1 items are currently selected, the origin_path is used to delete the file
+ from the originating context menu item.
+ If there are currently multiple items selected,
+ then the selection buffer is used to determine the files to be deleted.
+
+ Args:
+ origin_path(str): The file path associated with the widget making the call.
+ May or may not be the file targeted, depending on the selection rules.
+ origin_id(id): The entry ID associated with the widget making the call.
+ """
+ entry: Entry | None = None
+ pending: list[tuple[int, Path]] = []
+ deleted_count: int = 0
+
+ if len(self.selected) <= 1 and origin_path:
+ origin_id_ = origin_id
+ if not origin_id_:
+ with contextlib.suppress(IndexError):
+ origin_id_ = self.selected[0]
+
+ pending.append((origin_id_, Path(origin_path)))
+ elif (len(self.selected) > 1) or (len(self.selected) <= 1):
+ for item in self.selected:
+ entry = self.lib.get_entry(item)
+ filepath: Path = entry.path
+ pending.append((item, filepath))
+
+ if pending:
+ return_code = self.delete_file_confirmation(len(pending), pending[0][1])
+ # If there was a confirmation and not a cancellation
+ if (
+ return_code == QMessageBox.ButtonRole.DestructiveRole.value
+ and return_code != QMessageBox.ButtonRole.ActionRole.value
+ ):
+ for i, tup in enumerate(pending):
+ e_id, f = tup
+ if (origin_path == f) or (not origin_path):
+ self.preview_panel.thumb.stop_file_use()
+ if delete_file(self.lib.library_dir / f):
+ self.main_window.statusbar.showMessage(
+ Translations.translate_formatted(
+ "status.deleting_file", i=i, count=len(pending), path=f
+ )
+ )
+ self.main_window.statusbar.repaint()
+ self.lib.remove_entries([e_id])
+
+ deleted_count += 1
+ self.selected.clear()
+
+ if deleted_count > 0:
+ self.filter_items()
+ self.preview_panel.update_widgets()
+
+ if len(self.selected) <= 1 and deleted_count == 0:
+ self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
+ elif len(self.selected) <= 1 and deleted_count == 1:
+ self.main_window.statusbar.showMessage(
+ Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
+ )
+ elif len(self.selected) > 1 and deleted_count == 0:
+ self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
+ elif len(self.selected) > 1 and deleted_count < len(self.selected):
+ self.main_window.statusbar.showMessage(
+ Translations.translate_formatted(
+ "status.deleted_partial_warning", count=deleted_count
+ )
+ )
+ elif len(self.selected) > 1 and deleted_count == len(self.selected):
+ self.main_window.statusbar.showMessage(
+ Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
+ )
+ self.main_window.statusbar.repaint()
+
+ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
+ """A confirmation dialogue box for deleting files.
+
+ Args:
+ count(int): The number of files to be deleted.
+ filename(Path | None): The filename to show if only one file is to be deleted.
+ """
+ # NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the
+ # Recycle Bin. This is done without any warning, so this message is currently the
+ # best way I've got to inform the user.
+ # https://github.com/arsenetar/send2trash/issues/28
+ # This warning is applied to all platforms until at least macOS and Linux can be verified
+ # to not exhibit this same behavior.
+ perm_warning_msg = Translations.translate_formatted(
+ "trash.dialog.permanent_delete_warning", trash_term=trash_term()
+ )
+ perm_warning: str = (
+ f""
+ f"{perm_warning_msg}
"
+ )
+
+ msg = QMessageBox()
+ msg.setStyleSheet("font-weight:normal;")
+ msg.setTextFormat(Qt.TextFormat.RichText)
+ msg.setWindowTitle(
+ Translations["trash.title.singular"]
+ if count == 1
+ else Translations["trash.title.plural"]
+ )
+ msg.setIcon(QMessageBox.Icon.Warning)
+ if count <= 1:
+ msg_text = Translations.translate_formatted(
+ "trash.dialog.move.confirmation.singular", trash_term=trash_term()
+ )
+ msg.setText(
+ f"{msg_text}
"
+ f"{Translations["trash.dialog.disambiguation_warning.singular"]}
"
+ f"{filename if filename else ''}"
+ f"{perm_warning}
"
+ )
+ elif count > 1:
+ msg_text = Translations.translate_formatted(
+ "trash.dialog.move.confirmation.plural",
+ count=count,
+ trash_term=trash_term(),
+ )
+ msg.setText(
+ f"{msg_text}
"
+ f"{Translations["trash.dialog.disambiguation_warning.plural"]}
"
+ f"{perm_warning}
"
+ )
+
+ yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
+ msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
+ msg.setDefaultButton(yes_button)
+
+ return msg.exec()
+
def add_new_files_callback(self):
"""Run when user initiates adding new files to the Library."""
tracker = RefreshDirTracker(self.lib)
@@ -1315,9 +1468,11 @@ def set_select_actions_visibility(self):
if self.selected:
self.add_tag_to_selected_action.setEnabled(True)
self.clear_select_action.setEnabled(True)
+ self.delete_file_action.setEnabled(True)
else:
self.add_tag_to_selected_action.setEnabled(False)
self.clear_select_action.setEnabled(False)
+ self.delete_file_action.setEnabled(False)
def update_completions_list(self, text: str) -> None:
matches = re.search(
@@ -1425,6 +1580,9 @@ def update_thumbs(self):
if not entry:
continue
+ with catch_warnings(record=True):
+ item_thumb.delete_action.triggered.disconnect()
+
item_thumb.set_mode(ItemType.ENTRY)
item_thumb.set_item_id(entry.id)
item_thumb.show()
@@ -1470,6 +1628,11 @@ def update_thumbs(self):
)
)
)
+ item_thumb.delete_action.triggered.connect(
+ lambda checked=False, f=filenames[index], e_id=entry.id: self.delete_files_callback(
+ f, e_id
+ )
+ )
# Restore Selected Borders
is_selected = item_thumb.item_id in self.selected
diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py
index 008aaa72e..2ec049667 100644
--- a/tagstudio/src/qt/widgets/item_thumb.py
+++ b/tagstudio/src/qt/widgets/item_thumb.py
@@ -29,7 +29,7 @@
from src.core.media_types import MediaCategories, MediaType
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
-from src.qt.platform_strings import PlatformStrings
+from src.qt.platform_strings import open_file_str, trash_term
from src.qt.translations import Translations
from src.qt.widgets.thumb_button import ThumbButton
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -219,10 +219,17 @@ def __init__(
open_file_action = QAction(self)
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action.triggered.connect(self.opener.open_file)
- open_explorer_action = QAction(PlatformStrings.open_file_str, self)
+ open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
+
+ self.delete_action = QAction(self)
+ Translations.translate_qobject(
+ self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
+ )
+
self.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
+ self.thumb_button.addAction(self.delete_action)
# Static Badges ========================================================
diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py
index 247c7a5b3..137b2dea7 100644
--- a/tagstudio/src/qt/widgets/preview/preview_thumb.py
+++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py
@@ -6,6 +6,7 @@
import time
import typing
from pathlib import Path
+from warnings import catch_warnings
import cv2
import rawpy
@@ -24,7 +25,8 @@
from src.qt.helpers.file_tester import is_readable_video
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
-from src.qt.platform_strings import PlatformStrings
+from src.qt.platform_strings import open_file_str, trash_term
+from src.qt.resource_manager import ResourceManager
from src.qt.translations import Translations
from src.qt.widgets.media_player import MediaPlayer
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -54,7 +56,11 @@ def __init__(self, library: Library, driver: "QtDriver"):
self.open_file_action = QAction(self)
Translations.translate_qobject(self.open_file_action, "file.open_file")
- self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
+ self.open_explorer_action = QAction(open_file_str(), self)
+ self.delete_action = QAction(self)
+ Translations.translate_qobject(
+ self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
+ )
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
@@ -62,6 +68,7 @@ def __init__(self, library: Library, driver: "QtDriver"):
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
+ self.preview_img.addAction(self.delete_action)
self.preview_gif = QLabel()
self.preview_gif.setMinimumSize(*self.img_button_size)
@@ -69,10 +76,12 @@ def __init__(self, library: Library, driver: "QtDriver"):
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
self.preview_gif.addAction(self.open_file_action)
self.preview_gif.addAction(self.open_explorer_action)
+ self.preview_gif.addAction(self.delete_action)
self.preview_gif.hide()
self.gif_buffer: QBuffer = QBuffer()
self.preview_vid = VideoPlayer(driver)
+ self.preview_vid.addAction(self.delete_action)
self.preview_vid.hide()
self.thumb_renderer = ThumbRenderer(self.lib)
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
@@ -355,7 +364,7 @@ def update_preview(self, filepath: Path, ext: str) -> dict:
update_on_ratio_change=True,
)
- if self.preview_img.is_connected:
+ with catch_warnings(record=True):
self.preview_img.clicked.disconnect()
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
self.preview_img.is_connected = True
@@ -367,12 +376,31 @@ def update_preview(self, filepath: Path, ext: str) -> dict:
self.open_file_action.triggered.connect(self.opener.open_file)
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
+ with catch_warnings(record=True):
+ self.delete_action.triggered.disconnect()
+
+ self.delete_action.setText(
+ Translations.translate_formatted("trash.context.singular", trash_term=trash_term())
+ )
+ self.delete_action.triggered.connect(
+ lambda checked=False, f=filepath: self.driver.delete_files_callback(f)
+ )
+ self.delete_action.setEnabled(bool(filepath))
+
return stats
def hide_preview(self):
"""Completely hide the file preview."""
self.switch_preview("")
+ def stop_file_use(self):
+ """Stops the use of the currently previewed file. Used to release file permissions."""
+ logger.info("[PreviewThumb] Stopping file use in video playback...")
+ # This swaps the video out for a placeholder so the previous video's file
+ # is no longer in use by this object.
+ self.preview_vid.play(str(ResourceManager.get_path("placeholder_mp4")), QSize(8, 8))
+ self.preview_vid.hide()
+
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
self.update_image_size((self.size().width(), self.size().height()))
return super().resizeEvent(event)
diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py
index 7e5e6ba15..d02a06e49 100644
--- a/tagstudio/src/qt/widgets/video_player.py
+++ b/tagstudio/src/qt/widgets/video_player.py
@@ -30,7 +30,7 @@
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
from src.core.enums import SettingItems
from src.qt.helpers.file_opener import FileOpenerHelper
-from src.qt.platform_strings import PlatformStrings
+from src.qt.platform_strings import open_file_str
from src.qt.translations import Translations
if typing.TYPE_CHECKING:
@@ -130,7 +130,7 @@ def __init__(self, driver: "QtDriver") -> None:
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action.triggered.connect(self.opener.open_file)
- open_explorer_action = QAction(PlatformStrings.open_file_str, self)
+ open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.addAction(open_file_action)