Skip to content

Commit a3df70b

Browse files
authored
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
1 parent 466af1e commit a3df70b

File tree

9 files changed

+271
-15
lines changed

9 files changed

+271
-15
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ PySide6_Addons==6.8.0.1
1212
PySide6_Essentials==6.8.0.1
1313
PySide6==6.8.0.1
1414
rawpy==0.22.0
15+
Send2Trash==1.8.3
1516
SQLAlchemy==2.0.34
1617
structlog==24.4.0
1718
typing_extensions>=3.10.0.0,<=4.11.0
2.53 KB
Binary file not shown.

tagstudio/resources/translations/en.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@
157157
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
158158
"macros.running.dialog.title": "Running Macros on New Entries",
159159
"media_player.autoplay": "Autoplay",
160+
"menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}",
161+
"menu.delete_selected_files_plural": "Move Files to {trash_term}",
162+
"menu.delete_selected_files_singular": "Move File to {trash_term}",
160163
"menu.edit.ignore_list": "Ignore Files and Folders",
161164
"menu.edit.manage_file_extensions": "Manage File Extensions",
162165
"menu.edit.manage_tags": "Manage Tags",
@@ -195,6 +198,11 @@
195198
"sorting.direction.ascending": "Ascending",
196199
"sorting.direction.descending": "Descending",
197200
"splash.opening_library": "Opening Library \"{library_path}\"...",
201+
"status.deleted_file_plural": "Deleted {count} files!",
202+
"status.deleted_file_singular": "Deleted 1 file!",
203+
"status.deleted_none": "No files deleted.",
204+
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
205+
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
198206
"status.library_backup_in_progress": "Saving Library Backup...",
199207
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
200208
"status.library_closed": "Library Closed ({time_span})",
@@ -230,6 +238,18 @@
230238
"tag.shorthand": "Shorthand",
231239
"tag.tag_name_required": "Tag Name (Required)",
232240
"tag.view_limit": "View Limit:",
241+
"trash.context.ambiguous": "Move file(s) to {trash_term}",
242+
"trash.context.plural": "Move files to {trash_term}",
243+
"trash.context.singular": "Move file to {trash_term}",
244+
"trash.dialog.disambiguation_warning.plural": "This will remove them from TagStudio <i>AND</i> your file system!",
245+
"trash.dialog.disambiguation_warning.singular": "This will remove it from TagStudio <i>AND</i> your file system!",
246+
"trash.dialog.move.confirmation.plural": "Are you sure you want to move these {count} files to the {trash_term}?",
247+
"trash.dialog.move.confirmation.singular": "Are you sure you want to move this file to the {trash_term}?",
248+
"trash.dialog.permanent_delete_warning": "<b>WARNING!</b> If this file can't be moved to the {trash_term}, <b>it will be <b>permanently deleted!</b>",
249+
"trash.dialog.title.plural": "Delete Files",
250+
"trash.dialog.title.singular": "Delete File",
251+
"trash.name.generic": "Trash",
252+
"trash.name.windows": "Recycle Bin",
233253
"view.size.0": "Mini",
234254
"view.size.1": "Small",
235255
"view.size.2": "Medium",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
2+
# Licensed under the GPL-3.0 License.
3+
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
4+
5+
import logging
6+
from pathlib import Path
7+
8+
from send2trash import send2trash
9+
10+
logging.basicConfig(format="%(message)s", level=logging.INFO)
11+
12+
13+
def delete_file(path: str | Path) -> bool:
14+
"""Send a file to the system trash.
15+
16+
Args:
17+
path (str | Path): The path of the file to delete.
18+
"""
19+
_path = Path(path)
20+
try:
21+
logging.info(f"[delete_file] Sending to Trash: {_path}")
22+
send2trash(_path)
23+
return True
24+
except PermissionError as e:
25+
logging.error(f"[delete_file][ERROR] PermissionError: {e}")
26+
except FileNotFoundError:
27+
logging.error(f"[delete_file][ERROR] File Not Found: {_path}")
28+
except Exception as e:
29+
logging.error(e)
30+
return False
Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
1+
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
22
# Licensed under the GPL-3.0 License.
33
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
44

@@ -9,10 +9,17 @@
99
from src.qt.translations import Translations
1010

1111

12-
class PlatformStrings:
13-
open_file_str: str = Translations["file.open_location.generic"]
14-
12+
def open_file_str() -> str:
1513
if platform.system() == "Windows":
16-
open_file_str = Translations["file.open_location.windows"]
14+
return Translations["file.open_location.windows"]
1715
elif platform.system() == "Darwin":
18-
open_file_str = Translations["file.open_location.mac"]
16+
return Translations["file.open_location.mac"]
17+
else:
18+
return Translations["file.open_location.generic"]
19+
20+
21+
def trash_term() -> str:
22+
if platform.system() == "Windows":
23+
return Translations["trash.name.windows"]
24+
else:
25+
return Translations["trash.name.generic"]

tagstudio/src/qt/ts_qt.py

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
"""A Qt driver for TagStudio."""
99

10+
import contextlib
1011
import ctypes
1112
import dataclasses
1213
import math
@@ -67,13 +68,15 @@
6768
from src.core.library.alchemy.fields import _FieldID
6869
from src.core.library.alchemy.library import Entry, LibraryStatus
6970
from src.core.media_types import MediaCategories
71+
from src.core.palette import ColorType, UiColor, get_ui_color
7072
from src.core.query_lang.util import ParsingError
7173
from src.core.ts_core import TagStudioCore
7274
from src.core.utils.refresh_dir import RefreshDirTracker
7375
from src.core.utils.web import strip_web_protocol
7476
from src.qt.cache_manager import CacheManager
7577
from src.qt.flowlayout import FlowLayout
7678
from src.qt.helpers.custom_runnable import CustomRunnable
79+
from src.qt.helpers.file_deleter import delete_file
7780
from src.qt.helpers.function_iterator import FunctionIterator
7881
from src.qt.main_window import Ui_MainWindow
7982
from src.qt.modals.about import AboutModal
@@ -86,6 +89,7 @@
8689
from src.qt.modals.folders_to_tags import FoldersToTagsModal
8790
from src.qt.modals.tag_database import TagDatabasePanel
8891
from src.qt.modals.tag_search import TagSearchPanel
92+
from src.qt.platform_strings import trash_term
8993
from src.qt.resource_manager import ResourceManager
9094
from src.qt.splash import Splash
9195
from src.qt.translations import Translations
@@ -498,6 +502,17 @@ def start(self) -> None:
498502

499503
edit_menu.addSeparator()
500504

505+
self.delete_file_action = QAction(menu_bar)
506+
Translations.translate_qobject(
507+
self.delete_file_action, "menu.delete_selected_files_ambiguous", trash_term=trash_term()
508+
)
509+
self.delete_file_action.triggered.connect(lambda f="": self.delete_files_callback(f))
510+
self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
511+
self.delete_file_action.setEnabled(False)
512+
edit_menu.addAction(self.delete_file_action)
513+
514+
edit_menu.addSeparator()
515+
501516
self.manage_file_ext_action = QAction(menu_bar)
502517
Translations.translate_qobject(
503518
self.manage_file_ext_action, "menu.edit.manage_file_extensions"
@@ -839,10 +854,13 @@ def close_library(self, is_shutdown: bool = False):
839854

840855
self.main_window.setWindowTitle(self.base_title)
841856

842-
self.selected = []
843-
self.frame_content = []
857+
self.selected.clear()
858+
self.frame_content.clear()
844859
[x.set_mode(None) for x in self.item_thumbs]
845860

861+
self.set_clipboard_menu_viability()
862+
self.set_select_actions_visibility()
863+
846864
self.preview_panel.update_widgets()
847865
self.main_window.toggle_landing_page(enabled=True)
848866
self.main_window.pagination.setHidden(True)
@@ -937,6 +955,141 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]):
937955
for entry_id in self.selected:
938956
self.lib.add_tags_to_entry(entry_id, tag_ids)
939957

958+
def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None):
959+
"""Callback to send on or more files to the system trash.
960+
961+
If 0-1 items are currently selected, the origin_path is used to delete the file
962+
from the originating context menu item.
963+
If there are currently multiple items selected,
964+
then the selection buffer is used to determine the files to be deleted.
965+
966+
Args:
967+
origin_path(str): The file path associated with the widget making the call.
968+
May or may not be the file targeted, depending on the selection rules.
969+
origin_id(id): The entry ID associated with the widget making the call.
970+
"""
971+
entry: Entry | None = None
972+
pending: list[tuple[int, Path]] = []
973+
deleted_count: int = 0
974+
975+
if len(self.selected) <= 1 and origin_path:
976+
origin_id_ = origin_id
977+
if not origin_id_:
978+
with contextlib.suppress(IndexError):
979+
origin_id_ = self.selected[0]
980+
981+
pending.append((origin_id_, Path(origin_path)))
982+
elif (len(self.selected) > 1) or (len(self.selected) <= 1):
983+
for item in self.selected:
984+
entry = self.lib.get_entry(item)
985+
filepath: Path = entry.path
986+
pending.append((item, filepath))
987+
988+
if pending:
989+
return_code = self.delete_file_confirmation(len(pending), pending[0][1])
990+
# If there was a confirmation and not a cancellation
991+
if (
992+
return_code == QMessageBox.ButtonRole.DestructiveRole.value
993+
and return_code != QMessageBox.ButtonRole.ActionRole.value
994+
):
995+
for i, tup in enumerate(pending):
996+
e_id, f = tup
997+
if (origin_path == f) or (not origin_path):
998+
self.preview_panel.thumb.stop_file_use()
999+
if delete_file(self.lib.library_dir / f):
1000+
self.main_window.statusbar.showMessage(
1001+
Translations.translate_formatted(
1002+
"status.deleting_file", i=i, count=len(pending), path=f
1003+
)
1004+
)
1005+
self.main_window.statusbar.repaint()
1006+
self.lib.remove_entries([e_id])
1007+
1008+
deleted_count += 1
1009+
self.selected.clear()
1010+
1011+
if deleted_count > 0:
1012+
self.filter_items()
1013+
self.preview_panel.update_widgets()
1014+
1015+
if len(self.selected) <= 1 and deleted_count == 0:
1016+
self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
1017+
elif len(self.selected) <= 1 and deleted_count == 1:
1018+
self.main_window.statusbar.showMessage(
1019+
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
1020+
)
1021+
elif len(self.selected) > 1 and deleted_count == 0:
1022+
self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
1023+
elif len(self.selected) > 1 and deleted_count < len(self.selected):
1024+
self.main_window.statusbar.showMessage(
1025+
Translations.translate_formatted(
1026+
"status.deleted_partial_warning", count=deleted_count
1027+
)
1028+
)
1029+
elif len(self.selected) > 1 and deleted_count == len(self.selected):
1030+
self.main_window.statusbar.showMessage(
1031+
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
1032+
)
1033+
self.main_window.statusbar.repaint()
1034+
1035+
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
1036+
"""A confirmation dialogue box for deleting files.
1037+
1038+
Args:
1039+
count(int): The number of files to be deleted.
1040+
filename(Path | None): The filename to show if only one file is to be deleted.
1041+
"""
1042+
# NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the
1043+
# Recycle Bin. This is done without any warning, so this message is currently the
1044+
# best way I've got to inform the user.
1045+
# https://github.com/arsenetar/send2trash/issues/28
1046+
# This warning is applied to all platforms until at least macOS and Linux can be verified
1047+
# to not exhibit this same behavior.
1048+
perm_warning_msg = Translations.translate_formatted(
1049+
"trash.dialog.permanent_delete_warning", trash_term=trash_term()
1050+
)
1051+
perm_warning: str = (
1052+
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, UiColor.RED)}'>"
1053+
f"{perm_warning_msg}</h4>"
1054+
)
1055+
1056+
msg = QMessageBox()
1057+
msg.setStyleSheet("font-weight:normal;")
1058+
msg.setTextFormat(Qt.TextFormat.RichText)
1059+
msg.setWindowTitle(
1060+
Translations["trash.title.singular"]
1061+
if count == 1
1062+
else Translations["trash.title.plural"]
1063+
)
1064+
msg.setIcon(QMessageBox.Icon.Warning)
1065+
if count <= 1:
1066+
msg_text = Translations.translate_formatted(
1067+
"trash.dialog.move.confirmation.singular", trash_term=trash_term()
1068+
)
1069+
msg.setText(
1070+
f"<h3>{msg_text}</h3>"
1071+
f"<h4>{Translations["trash.dialog.disambiguation_warning.singular"]}</h4>"
1072+
f"{filename if filename else ''}"
1073+
f"{perm_warning}<br>"
1074+
)
1075+
elif count > 1:
1076+
msg_text = Translations.translate_formatted(
1077+
"trash.dialog.move.confirmation.plural",
1078+
count=count,
1079+
trash_term=trash_term(),
1080+
)
1081+
msg.setText(
1082+
f"<h3>{msg_text}</h3>"
1083+
f"<h4>{Translations["trash.dialog.disambiguation_warning.plural"]}</h4>"
1084+
f"{perm_warning}<br>"
1085+
)
1086+
1087+
yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole)
1088+
msg.addButton("&No", QMessageBox.ButtonRole.NoRole)
1089+
msg.setDefaultButton(yes_button)
1090+
1091+
return msg.exec()
1092+
9401093
def add_new_files_callback(self):
9411094
"""Run when user initiates adding new files to the Library."""
9421095
tracker = RefreshDirTracker(self.lib)
@@ -1315,9 +1468,11 @@ def set_select_actions_visibility(self):
13151468
if self.selected:
13161469
self.add_tag_to_selected_action.setEnabled(True)
13171470
self.clear_select_action.setEnabled(True)
1471+
self.delete_file_action.setEnabled(True)
13181472
else:
13191473
self.add_tag_to_selected_action.setEnabled(False)
13201474
self.clear_select_action.setEnabled(False)
1475+
self.delete_file_action.setEnabled(False)
13211476

13221477
def update_completions_list(self, text: str) -> None:
13231478
matches = re.search(
@@ -1425,6 +1580,9 @@ def update_thumbs(self):
14251580
if not entry:
14261581
continue
14271582

1583+
with catch_warnings(record=True):
1584+
item_thumb.delete_action.triggered.disconnect()
1585+
14281586
item_thumb.set_mode(ItemType.ENTRY)
14291587
item_thumb.set_item_id(entry.id)
14301588
item_thumb.show()
@@ -1470,6 +1628,11 @@ def update_thumbs(self):
14701628
)
14711629
)
14721630
)
1631+
item_thumb.delete_action.triggered.connect(
1632+
lambda checked=False, f=filenames[index], e_id=entry.id: self.delete_files_callback(
1633+
f, e_id
1634+
)
1635+
)
14731636

14741637
# Restore Selected Borders
14751638
is_selected = item_thumb.item_id in self.selected

tagstudio/src/qt/widgets/item_thumb.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from src.core.media_types import MediaCategories, MediaType
3030
from src.qt.flowlayout import FlowWidget
3131
from src.qt.helpers.file_opener import FileOpenerHelper
32-
from src.qt.platform_strings import PlatformStrings
32+
from src.qt.platform_strings import open_file_str, trash_term
3333
from src.qt.translations import Translations
3434
from src.qt.widgets.thumb_button import ThumbButton
3535
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -219,10 +219,17 @@ def __init__(
219219
open_file_action = QAction(self)
220220
Translations.translate_qobject(open_file_action, "file.open_file")
221221
open_file_action.triggered.connect(self.opener.open_file)
222-
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
222+
open_explorer_action = QAction(open_file_str(), self)
223223
open_explorer_action.triggered.connect(self.opener.open_explorer)
224+
225+
self.delete_action = QAction(self)
226+
Translations.translate_qobject(
227+
self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
228+
)
229+
224230
self.thumb_button.addAction(open_file_action)
225231
self.thumb_button.addAction(open_explorer_action)
232+
self.thumb_button.addAction(self.delete_action)
226233

227234
# Static Badges ========================================================
228235

0 commit comments

Comments
 (0)