From a3df70bb8dea45d16b55a08c96bbc100c84378bf Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 5 Feb 2025 19:25:10 -0800 Subject: [PATCH] feat: port file trashing (#409) to v9.5 (#792) * feat: port file trashing (#409) to sql * translations: translate file deletion actions * fix: rename method from refactor conflict * refactor: implement feedback --- requirements.txt | 1 + tagstudio/resources/qt/videos/placeholder.mp4 | Bin 0 -> 2590 bytes tagstudio/resources/translations/en.json | 20 +++ tagstudio/src/qt/helpers/file_deleter.py | 30 ++++ tagstudio/src/qt/platform_strings.py | 19 +- tagstudio/src/qt/ts_qt.py | 167 +++++++++++++++++- tagstudio/src/qt/widgets/item_thumb.py | 11 +- .../src/qt/widgets/preview/preview_thumb.py | 34 +++- tagstudio/src/qt/widgets/video_player.py | 4 +- 9 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 tagstudio/resources/qt/videos/placeholder.mp4 create mode 100644 tagstudio/src/qt/helpers/file_deleter.py diff --git a/requirements.txt b/requirements.txt index 9eb29039..ff6e6d4a 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 0000000000000000000000000000000000000000..1e22e4c724aeefa2a44b44f3462f075495373b1e GIT binary patch literal 2590 zcmdT`&1)M+6d$c+MRpZejve9mp}-u$ss+Ac30A({p#*$ z69)o0rq>>d>7mECy@gW9C6~}b4~5WMA(wpgKWHiKAw_v_b~KVVA1#Fj^0YJW^Y`BD z%)UW{5ZdBfp%VulAsRsrLuZLu^TI}r5K?SKwoS;`_nyT#=t$Z`8Ri4+^Q(Km*evE7 z6#N9HLhifi;tsg-o%<^{fY%G-r?kJ=g{R?KbVuL#edoQ?jq{Ef1#!^gbfcm#HRw6t z3@kgMo3+YfrA`p`YhnE1Pv6dbz8$YW`1;n5XSNsJKjO$V>a=6%i%ay1m|J0N=#5IX zTBTvjSYD=X;u;OwG<4ehG=$)G5E{!mb*uuCc}I#=Bht~(sC%s?ZxCakTo2bHEWM67~K6_N2 z6P62T$$ga{dvY~j*ku`47KzI5&7ukuDn{lhyrnD>RhY@5if^kJi7HMji$oOJ&- zRg6SU9#9sEntUgVDjiZW5>+~^ED}{ZmPJh+Q85xVbyQg-YU=$gs(f6U|T!}b>4R4!4(BA%&QH8W;ygU7)snT@!i3p z-SsSoflNK?#ryH`58x%_WXEzNA;FY57F>rz+5|n=V7}$r7^Ag4$Hzjmi6@fEH44Cj zmHOtbbTMc|f2kL_G?lgEF>ksc--&rF4k@*h9lVo4MjFj24Pe>;5eL$95vh4(p6mBP zt3cNE9Ngpm$1lClbLu*uF#|X9P4_C~&k<~9QVv-h0>^?l=3*|+0>o4oTMgR*uY+fV zG>F|chq2SC9gao#MQ)+I9z8GXA|vy#R#QmBwc=fd>*nCQCVj}jt{nuv?|&SycV>Xa zfN%{BV7D`UrH%BJ+_(SMSE5Qfy`rz=N=WYAf9fHbuR7X8vIkYEj~vflhd%+sHX=XE zHY)jP8)59fXdkjI+ozFfpM{Z{xQCL`I|2_EDEsX~oY=z`eGixe2Y%Os?*Ts|Y1?&P zm?Y_0oB{VeTW}D`kAND 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 00000000..a8e50e17 --- /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 23851dd4..70426b12 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 @@ import platform 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 9c732b1c..3e3c8390 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.enums import ( 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.core.utils.web import strip_web_protocol 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.fix_unlinked import FixUnlinkedEntriesModal 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 @@ class QtDriver(DriverMixin, QObject): 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 @@ class QtDriver(DriverMixin, QObject): 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 @@ class QtDriver(DriverMixin, QObject): 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 @@ class QtDriver(DriverMixin, QObject): 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 @@ class QtDriver(DriverMixin, QObject): 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 @@ class QtDriver(DriverMixin, QObject): ) ) ) + 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 008aaa72..2ec04966 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -29,7 +29,7 @@ from src.core.library import ItemType, Library 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 @@ class ItemThumb(FlowWidget): 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 247c7a5b..137b2dea 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -6,6 +6,7 @@ import io 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_opener import FileOpenerHelper, open_file 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 @@ class PreviewThumb(QWidget): 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 @@ class PreviewThumb(QWidget): 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 @@ class PreviewThumb(QWidget): 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 @@ class PreviewThumb(QWidget): 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 @@ class PreviewThumb(QWidget): 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 7e5e6ba1..d02a06e4 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -30,7 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget 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 @@ class VideoPlayer(QGraphicsView): 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)