|
7 | 7 |
|
8 | 8 | """A Qt driver for TagStudio.""" |
9 | 9 |
|
| 10 | +import contextlib |
10 | 11 | import ctypes |
11 | 12 | import dataclasses |
12 | 13 | import math |
|
67 | 68 | from src.core.library.alchemy.fields import _FieldID |
68 | 69 | from src.core.library.alchemy.library import Entry, LibraryStatus |
69 | 70 | from src.core.media_types import MediaCategories |
| 71 | +from src.core.palette import ColorType, UiColor, get_ui_color |
70 | 72 | from src.core.query_lang.util import ParsingError |
71 | 73 | from src.core.ts_core import TagStudioCore |
72 | 74 | from src.core.utils.refresh_dir import RefreshDirTracker |
73 | 75 | from src.core.utils.web import strip_web_protocol |
74 | 76 | from src.qt.cache_manager import CacheManager |
75 | 77 | from src.qt.flowlayout import FlowLayout |
76 | 78 | from src.qt.helpers.custom_runnable import CustomRunnable |
| 79 | +from src.qt.helpers.file_deleter import delete_file |
77 | 80 | from src.qt.helpers.function_iterator import FunctionIterator |
78 | 81 | from src.qt.main_window import Ui_MainWindow |
79 | 82 | from src.qt.modals.about import AboutModal |
|
86 | 89 | from src.qt.modals.folders_to_tags import FoldersToTagsModal |
87 | 90 | from src.qt.modals.tag_database import TagDatabasePanel |
88 | 91 | from src.qt.modals.tag_search import TagSearchPanel |
| 92 | +from src.qt.platform_strings import trash_term |
89 | 93 | from src.qt.resource_manager import ResourceManager |
90 | 94 | from src.qt.splash import Splash |
91 | 95 | from src.qt.translations import Translations |
@@ -498,6 +502,17 @@ def start(self) -> None: |
498 | 502 |
|
499 | 503 | edit_menu.addSeparator() |
500 | 504 |
|
| 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 | + |
501 | 516 | self.manage_file_ext_action = QAction(menu_bar) |
502 | 517 | Translations.translate_qobject( |
503 | 518 | self.manage_file_ext_action, "menu.edit.manage_file_extensions" |
@@ -839,10 +854,13 @@ def close_library(self, is_shutdown: bool = False): |
839 | 854 |
|
840 | 855 | self.main_window.setWindowTitle(self.base_title) |
841 | 856 |
|
842 | | - self.selected = [] |
843 | | - self.frame_content = [] |
| 857 | + self.selected.clear() |
| 858 | + self.frame_content.clear() |
844 | 859 | [x.set_mode(None) for x in self.item_thumbs] |
845 | 860 |
|
| 861 | + self.set_clipboard_menu_viability() |
| 862 | + self.set_select_actions_visibility() |
| 863 | + |
846 | 864 | self.preview_panel.update_widgets() |
847 | 865 | self.main_window.toggle_landing_page(enabled=True) |
848 | 866 | self.main_window.pagination.setHidden(True) |
@@ -937,6 +955,141 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]): |
937 | 955 | for entry_id in self.selected: |
938 | 956 | self.lib.add_tags_to_entry(entry_id, tag_ids) |
939 | 957 |
|
| 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 | + |
940 | 1093 | def add_new_files_callback(self): |
941 | 1094 | """Run when user initiates adding new files to the Library.""" |
942 | 1095 | tracker = RefreshDirTracker(self.lib) |
@@ -1315,9 +1468,11 @@ def set_select_actions_visibility(self): |
1315 | 1468 | if self.selected: |
1316 | 1469 | self.add_tag_to_selected_action.setEnabled(True) |
1317 | 1470 | self.clear_select_action.setEnabled(True) |
| 1471 | + self.delete_file_action.setEnabled(True) |
1318 | 1472 | else: |
1319 | 1473 | self.add_tag_to_selected_action.setEnabled(False) |
1320 | 1474 | self.clear_select_action.setEnabled(False) |
| 1475 | + self.delete_file_action.setEnabled(False) |
1321 | 1476 |
|
1322 | 1477 | def update_completions_list(self, text: str) -> None: |
1323 | 1478 | matches = re.search( |
@@ -1425,6 +1580,9 @@ def update_thumbs(self): |
1425 | 1580 | if not entry: |
1426 | 1581 | continue |
1427 | 1582 |
|
| 1583 | + with catch_warnings(record=True): |
| 1584 | + item_thumb.delete_action.triggered.disconnect() |
| 1585 | + |
1428 | 1586 | item_thumb.set_mode(ItemType.ENTRY) |
1429 | 1587 | item_thumb.set_item_id(entry.id) |
1430 | 1588 | item_thumb.show() |
@@ -1470,6 +1628,11 @@ def update_thumbs(self): |
1470 | 1628 | ) |
1471 | 1629 | ) |
1472 | 1630 | ) |
| 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 | + ) |
1473 | 1636 |
|
1474 | 1637 | # Restore Selected Borders |
1475 | 1638 | is_selected = item_thumb.item_id in self.selected |
|
0 commit comments