From b6e0efec65a403942a81d44ab59ab22b989c9463 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 4 Feb 2025 13:31:24 -0800 Subject: [PATCH 1/4] feat: port file trashing (#409) to sql --- requirements.txt | 1 + tagstudio/resources/qt/videos/placeholder.mp4 | Bin 0 -> 2590 bytes tagstudio/src/qt/helpers/file_deleter.py | 30 ++++ tagstudio/src/qt/ts_qt.py | 134 ++++++++++++++++++ tagstudio/src/qt/widgets/item_thumb.py | 8 ++ .../src/qt/widgets/preview/preview_thumb.py | 29 +++- 6 files changed, 201 insertions(+), 1 deletion(-) 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 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 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`k bool: + """Sends 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/ts_qt.py b/tagstudio/src/qt/ts_qt.py index c6363a314..84674f54b 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -7,15 +7,18 @@ """A Qt driver for TagStudio.""" +import contextlib import ctypes import dataclasses import math import os +import platform import re import sys import time from pathlib import Path from queue import Queue +from warnings import catch_warnings # this import has side-effect of import PySide resources import src.qt.resources_rc # noqa: F401 @@ -66,6 +69,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 @@ -73,6 +77,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 @@ -483,6 +488,13 @@ def start(self) -> None: edit_menu.addSeparator() + self.delete_file_action = QAction("Delete Selected File(s)", menu_bar) + self.delete_file_action.triggered.connect(lambda f="": self.delete_files_callback(f)) + self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete) + 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" @@ -902,6 +914,120 @@ 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 and not origin_path): + 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]) + logger.info(return_code) + # If there was a confirmation and not a cancellation + if return_code == 2 and return_code != 3: + 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( + f'Deleting file [{i}/{len(pending)}]: "{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("No files deleted.") + elif len(self.selected) <= 1 and deleted_count == 1: + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!") + elif len(self.selected) > 1 and deleted_count == 0: + self.main_window.statusbar.showMessage("No files deleted.") + elif len(self.selected) > 1 and deleted_count < len(self.selected): + self.main_window.statusbar.showMessage( + f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! " + f"Check if any of the files are currently missing or in use." + ) + elif len(self.selected) > 1 and deleted_count == len(self.selected): + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!") + 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. + """ + trash_term: str = "Trash" + if platform.system == "Windows": + trash_term = "Recycle Bin" + # 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: str = ( + f"

" + f"WARNING! If this file can't be moved to the {trash_term}, " + f"it will be permanently deleted!

" + ) + + msg = QMessageBox() + msg.setTextFormat(Qt.TextFormat.RichText) + msg.setWindowTitle("Delete File" if count == 1 else "Delete Files") + msg.setIcon(QMessageBox.Icon.Warning) + if count <= 1: + msg.setText( + f"

Are you sure you want to move this file to the {trash_term}?

" + "

This will remove it from TagStudio AND your file system!

" + f"{filename if filename else ''}" + f"{perm_warning}
" + ) + elif count > 1: + msg.setText( + f"

Are you sure you want to move these {count} files to the {trash_term}?

" + "

This will remove them from TagStudio AND your file system!

" + 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 show_tag_manager(self): self.modal = PanelModal( widget=TagDatabasePanel(self.lib), @@ -1412,6 +1538,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() @@ -1457,6 +1586,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..694e66bc8 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -1,6 +1,7 @@ # Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import platform import time import typing from enum import Enum @@ -221,8 +222,15 @@ def __init__( open_file_action.triggered.connect(self.opener.open_file) open_explorer_action = QAction(PlatformStrings.open_file_str, self) open_explorer_action.triggered.connect(self.opener.open_explorer) + + trash_term: str = "Trash" + if platform.system() == "Windows": + trash_term = "Recycle Bin" + self.delete_action: QAction = QAction(f"Send file to {trash_term}", self) + 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..d5e397265 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -3,9 +3,11 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import io +import platform import time import typing from pathlib import Path +from warnings import catch_warnings import cv2 import rawpy @@ -25,6 +27,7 @@ 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.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 @@ -55,6 +58,10 @@ 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.trash_term: str = "Trash" + if platform.system() == "Windows": + self.trash_term = "Recycle Bin" + self.delete_action = QAction(f"Send file to {self.trash_term}", self) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) @@ -62,6 +69,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 +77,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 +365,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 +377,29 @@ 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(f"Send file to {self.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) From 7b1759affae9283aa8656069193c2e6d57474946 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:25:35 -0800 Subject: [PATCH 2/4] translations: translate file deletion actions --- tagstudio/resources/translations/en.json | 20 +++++ tagstudio/src/qt/helpers/file_deleter.py | 2 +- tagstudio/src/qt/ts_qt.py | 74 ++++++++++++++----- tagstudio/src/qt/widgets/item_thumb.py | 9 ++- .../src/qt/widgets/preview/preview_thumb.py | 9 ++- 5 files changed, 87 insertions(+), 27 deletions(-) diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 8b9542785..69e041b84 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": "Deleting {count} files!", + "status.deleted_file_singular": "Deleting 1 file!", + "status.deleted_none": "Deleting file [{i}/{count}]: \"{path}\"...", + "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})", @@ -228,6 +236,18 @@ "tag.search_for_tag": "Search for Tag", "tag.shorthand": "Shorthand", "tag.tag_name_required": "Tag Name (Required)", + "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.recycle_bin": "Recycle Bin", + "trash.name.trash": "Trash", "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 index 4a94e6be5..a8e50e17c 100644 --- a/tagstudio/src/qt/helpers/file_deleter.py +++ b/tagstudio/src/qt/helpers/file_deleter.py @@ -11,7 +11,7 @@ def delete_file(path: str | Path) -> bool: - """Sends a file to the system trash. + """Send a file to the system trash. Args: path (str | Path): The path of the file to delete. diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 84674f54b..3d5348fa7 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -488,9 +488,17 @@ def start(self) -> None: edit_menu.addSeparator() - self.delete_file_action = QAction("Delete Selected File(s)", menu_bar) + self.delete_file_action = QAction(menu_bar) + trash_term: str = Translations["trash.name.trash"] + if platform.system() == "Windows": + trash_term = Translations["trash.name.recycle_bin"] + + 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() @@ -816,10 +824,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_add_to_selected_visibility() + self.preview_panel.update_widgets() self.main_window.toggle_landing_page(enabled=True) self.main_window.pagination.setHidden(True) @@ -955,7 +966,9 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = self.preview_panel.thumb.stop_file_use() if delete_file(self.lib.library_dir / f): self.main_window.statusbar.showMessage( - f'Deleting file [{i}/{len(pending)}]: "{f}"...' + Translations.translate_formatted( + "status.deleting_file", i=i, count=len(pending), path=f + ) ) self.main_window.statusbar.repaint() self.lib.remove_entries([e_id]) @@ -968,18 +981,23 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = self.preview_panel.update_widgets() if len(self.selected) <= 1 and deleted_count == 0: - self.main_window.statusbar.showMessage("No files deleted.") + self.main_window.statusbar.showMessage(Translations["status.deleted_none"]) elif len(self.selected) <= 1 and deleted_count == 1: - self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!") + 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("No files deleted.") + 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( - f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! " - f"Check if any of the files are currently missing or in use." + 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(f"Deleted {deleted_count} files!") + 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: @@ -989,36 +1007,50 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> count(int): The number of files to be deleted. filename(Path | None): The filename to show if only one file is to be deleted. """ - trash_term: str = "Trash" - if platform.system == "Windows": - trash_term = "Recycle Bin" + trash_term: str = Translations["trash.name.trash"] + if platform.system() == "Windows": + trash_term = Translations["trash.name.recycle_bin"] # 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"WARNING! If this file can't be moved to the {trash_term}, " - f"it will be permanently deleted!

" + f"{perm_warning_msg}" ) msg = QMessageBox() + msg.setStyleSheet("font-weight:normal;") msg.setTextFormat(Qt.TextFormat.RichText) - msg.setWindowTitle("Delete File" if count == 1 else "Delete Files") + 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"

Are you sure you want to move this file to the {trash_term}?

" - "

This will remove it from TagStudio AND your file system!

" + 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"

Are you sure you want to move these {count} files to the {trash_term}?

" - "

This will remove them from TagStudio AND your file system!

" + f"

{msg_text}

" + f"

{Translations["trash.dialog.disambiguation_warning.plural"]}

" f"{perm_warning}
" ) @@ -1428,9 +1460,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( diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 694e66bc8..4c0c245f4 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -223,10 +223,13 @@ def __init__( open_explorer_action = QAction(PlatformStrings.open_file_str, self) open_explorer_action.triggered.connect(self.opener.open_explorer) - trash_term: str = "Trash" + trash_term: str = Translations["trash.name.trash"] if platform.system() == "Windows": - trash_term = "Recycle Bin" - self.delete_action: QAction = QAction(f"Send file to {trash_term}", self) + trash_term = Translations["trash.name.recycle_bin"] + 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) diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index d5e397265..99a5ba9f2 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -58,10 +58,13 @@ 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.trash_term: str = "Trash" + self.trash_term: str = Translations["trash.name.trash"] if platform.system() == "Windows": - self.trash_term = "Recycle Bin" - self.delete_action = QAction(f"Send file to {self.trash_term}", self) + self.trash_term = Translations["trash.name.recycle_bin"] + self.delete_action = QAction(self) + Translations.translate_qobject( + self.delete_action, "trash.context.ambiguous", trash_term=self.trash_term + ) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) From aee487aa6759fe14c9b952b14c3ab24407eb71cd Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:30:55 -0800 Subject: [PATCH 3/4] fix: rename method from refactor conflict --- tagstudio/src/qt/ts_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 3d5348fa7..fbdc7d916 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -829,7 +829,7 @@ def close_library(self, is_shutdown: bool = False): [x.set_mode(None) for x in self.item_thumbs] self.set_clipboard_menu_viability() - self.set_add_to_selected_visibility() + self.set_select_actions_visibility() self.preview_panel.update_widgets() self.main_window.toggle_landing_page(enabled=True) From 7bc2cf7d83731bfed6abb01ff9cb3a3b2157ef5a Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 5 Feb 2025 15:39:36 -0800 Subject: [PATCH 4/4] refactor: implement feedback --- tagstudio/resources/translations/en.json | 10 +++---- tagstudio/src/qt/platform_strings.py | 19 +++++++++---- tagstudio/src/qt/ts_qt.py | 28 ++++++++----------- tagstudio/src/qt/widgets/item_thumb.py | 10 ++----- .../src/qt/widgets/preview/preview_thumb.py | 14 ++++------ tagstudio/src/qt/widgets/video_player.py | 4 +-- 6 files changed, 41 insertions(+), 44 deletions(-) diff --git a/tagstudio/resources/translations/en.json b/tagstudio/resources/translations/en.json index 69e041b84..f4cc5cb82 100644 --- a/tagstudio/resources/translations/en.json +++ b/tagstudio/resources/translations/en.json @@ -198,9 +198,9 @@ "sorting.direction.ascending": "Ascending", "sorting.direction.descending": "Descending", "splash.opening_library": "Opening Library \"{library_path}\"...", - "status.deleted_file_plural": "Deleting {count} files!", - "status.deleted_file_singular": "Deleting 1 file!", - "status.deleted_none": "Deleting file [{i}/{count}]: \"{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...", @@ -246,8 +246,8 @@ "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.recycle_bin": "Recycle Bin", - "trash.name.trash": "Trash", + "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/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 fbdc7d916..2ec813690 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -12,7 +12,6 @@ import dataclasses import math import os -import platform import re import sys import time @@ -90,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 @@ -489,12 +489,8 @@ def start(self) -> None: edit_menu.addSeparator() self.delete_file_action = QAction(menu_bar) - trash_term: str = Translations["trash.name.trash"] - if platform.system() == "Windows": - trash_term = Translations["trash.name.recycle_bin"] - Translations.translate_qobject( - self.delete_file_action, "menu.delete_selected_files_ambiguous", trash_term=trash_term + 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) @@ -949,7 +945,7 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = origin_id_ = self.selected[0] pending.append((origin_id_, Path(origin_path))) - elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not 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 @@ -957,9 +953,11 @@ def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = if pending: return_code = self.delete_file_confirmation(len(pending), pending[0][1]) - logger.info(return_code) # If there was a confirmation and not a cancellation - if return_code == 2 and return_code != 3: + 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): @@ -1007,9 +1005,6 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> count(int): The number of files to be deleted. filename(Path | None): The filename to show if only one file is to be deleted. """ - trash_term: str = Translations["trash.name.trash"] - if platform.system() == "Windows": - trash_term = Translations["trash.name.recycle_bin"] # 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. @@ -1017,8 +1012,7 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> # 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, + "trash.dialog.permanent_delete_warning", trash_term=trash_term() ) perm_warning: str = ( f"

" @@ -1036,7 +1030,7 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> msg.setIcon(QMessageBox.Icon.Warning) if count <= 1: msg_text = Translations.translate_formatted( - "trash.dialog.move.confirmation.singular", trash_term=trash_term + "trash.dialog.move.confirmation.singular", trash_term=trash_term() ) msg.setText( f"

{msg_text}

" @@ -1046,7 +1040,9 @@ def delete_file_confirmation(self, count: int, filename: Path | None = None) -> ) elif count > 1: msg_text = Translations.translate_formatted( - "trash.dialog.move.confirmation.plural", count=count, trash_term=trash_term + "trash.dialog.move.confirmation.plural", + count=count, + trash_term=trash_term(), ) msg.setText( f"

{msg_text}

" diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 4c0c245f4..2ec049667 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -1,7 +1,6 @@ # Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import platform import time import typing from enum import Enum @@ -30,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 @@ -220,15 +219,12 @@ 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) - trash_term: str = Translations["trash.name.trash"] - if platform.system() == "Windows": - trash_term = Translations["trash.name.recycle_bin"] self.delete_action = QAction(self) Translations.translate_qobject( - self.delete_action, "trash.context.ambiguous", trash_term=trash_term + self.delete_action, "trash.context.ambiguous", trash_term=trash_term() ) self.thumb_button.addAction(open_file_action) diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 99a5ba9f2..137b2dea7 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -3,7 +3,6 @@ # Created for TagStudio: https://github.com/CyanVoxel/TagStudio import io -import platform import time import typing from pathlib import Path @@ -26,7 +25,7 @@ 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 @@ -57,13 +56,10 @@ 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.trash_term: str = Translations["trash.name.trash"] - if platform.system() == "Windows": - self.trash_term = Translations["trash.name.recycle_bin"] + 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=self.trash_term + self.delete_action, "trash.context.ambiguous", trash_term=trash_term() ) self.preview_img = QPushButtonWrapper() @@ -383,7 +379,9 @@ def update_preview(self, filepath: Path, ext: str) -> dict: with catch_warnings(record=True): self.delete_action.triggered.disconnect() - self.delete_action.setText(f"Send file to {self.trash_term}") + 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) ) 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)