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)