|
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
|
13 | 14 | import os
|
| 15 | +import platform |
14 | 16 | import re
|
15 | 17 | import sys
|
16 | 18 | import time
|
17 | 19 | from pathlib import Path
|
18 | 20 | from queue import Queue
|
| 21 | +from warnings import catch_warnings |
19 | 22 |
|
20 | 23 | # this import has side-effect of import PySide resources
|
21 | 24 | import src.qt.resources_rc # noqa: F401
|
|
66 | 69 | from src.core.library.alchemy.fields import _FieldID
|
67 | 70 | from src.core.library.alchemy.library import Entry, LibraryStatus
|
68 | 71 | from src.core.media_types import MediaCategories
|
| 72 | +from src.core.palette import ColorType, UiColor, get_ui_color |
69 | 73 | from src.core.query_lang.util import ParsingError
|
70 | 74 | from src.core.ts_core import TagStudioCore
|
71 | 75 | from src.core.utils.refresh_dir import RefreshDirTracker
|
72 | 76 | from src.core.utils.web import strip_web_protocol
|
73 | 77 | from src.qt.cache_manager import CacheManager
|
74 | 78 | from src.qt.flowlayout import FlowLayout
|
75 | 79 | from src.qt.helpers.custom_runnable import CustomRunnable
|
| 80 | +from src.qt.helpers.file_deleter import delete_file |
76 | 81 | from src.qt.helpers.function_iterator import FunctionIterator
|
77 | 82 | from src.qt.main_window import Ui_MainWindow
|
78 | 83 | from src.qt.modals.about import AboutModal
|
@@ -483,6 +488,13 @@ def start(self) -> None:
|
483 | 488 |
|
484 | 489 | edit_menu.addSeparator()
|
485 | 490 |
|
| 491 | + self.delete_file_action = QAction("Delete Selected File(s)", menu_bar) |
| 492 | + self.delete_file_action.triggered.connect(lambda f="": self.delete_files_callback(f)) |
| 493 | + self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete) |
| 494 | + edit_menu.addAction(self.delete_file_action) |
| 495 | + |
| 496 | + edit_menu.addSeparator() |
| 497 | + |
486 | 498 | self.manage_file_ext_action = QAction(menu_bar)
|
487 | 499 | Translations.translate_qobject(
|
488 | 500 | 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]):
|
902 | 914 | for entry_id in self.selected:
|
903 | 915 | self.lib.add_tags_to_entry(entry_id, tag_ids)
|
904 | 916 |
|
| 917 | + def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None): |
| 918 | + """Callback to send on or more files to the system trash. |
| 919 | +
|
| 920 | + If 0-1 items are currently selected, the origin_path is used to delete the file |
| 921 | + from the originating context menu item. |
| 922 | + If there are currently multiple items selected, |
| 923 | + then the selection buffer is used to determine the files to be deleted. |
| 924 | +
|
| 925 | + Args: |
| 926 | + origin_path(str): The file path associated with the widget making the call. |
| 927 | + May or may not be the file targeted, depending on the selection rules. |
| 928 | + origin_id(id): The entry ID associated with the widget making the call. |
| 929 | + """ |
| 930 | + entry: Entry | None = None |
| 931 | + pending: list[tuple[int, Path]] = [] |
| 932 | + deleted_count: int = 0 |
| 933 | + |
| 934 | + if len(self.selected) <= 1 and origin_path: |
| 935 | + origin_id_ = origin_id |
| 936 | + if not origin_id_: |
| 937 | + with contextlib.suppress(IndexError): |
| 938 | + origin_id_ = self.selected[0] |
| 939 | + |
| 940 | + pending.append((origin_id_, Path(origin_path))) |
| 941 | + elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not origin_path): |
| 942 | + for item in self.selected: |
| 943 | + entry = self.lib.get_entry(item) |
| 944 | + filepath: Path = entry.path |
| 945 | + pending.append((item, filepath)) |
| 946 | + |
| 947 | + if pending: |
| 948 | + return_code = self.delete_file_confirmation(len(pending), pending[0][1]) |
| 949 | + logger.info(return_code) |
| 950 | + # If there was a confirmation and not a cancellation |
| 951 | + if return_code == 2 and return_code != 3: |
| 952 | + for i, tup in enumerate(pending): |
| 953 | + e_id, f = tup |
| 954 | + if (origin_path == f) or (not origin_path): |
| 955 | + self.preview_panel.thumb.stop_file_use() |
| 956 | + if delete_file(self.lib.library_dir / f): |
| 957 | + self.main_window.statusbar.showMessage( |
| 958 | + f'Deleting file [{i}/{len(pending)}]: "{f}"...' |
| 959 | + ) |
| 960 | + self.main_window.statusbar.repaint() |
| 961 | + self.lib.remove_entries([e_id]) |
| 962 | + |
| 963 | + deleted_count += 1 |
| 964 | + self.selected.clear() |
| 965 | + |
| 966 | + if deleted_count > 0: |
| 967 | + self.filter_items() |
| 968 | + self.preview_panel.update_widgets() |
| 969 | + |
| 970 | + if len(self.selected) <= 1 and deleted_count == 0: |
| 971 | + self.main_window.statusbar.showMessage("No files deleted.") |
| 972 | + elif len(self.selected) <= 1 and deleted_count == 1: |
| 973 | + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} file!") |
| 974 | + elif len(self.selected) > 1 and deleted_count == 0: |
| 975 | + self.main_window.statusbar.showMessage("No files deleted.") |
| 976 | + elif len(self.selected) > 1 and deleted_count < len(self.selected): |
| 977 | + self.main_window.statusbar.showMessage( |
| 978 | + f"Only deleted {deleted_count} file{'' if deleted_count == 1 else 's'}! " |
| 979 | + f"Check if any of the files are currently missing or in use." |
| 980 | + ) |
| 981 | + elif len(self.selected) > 1 and deleted_count == len(self.selected): |
| 982 | + self.main_window.statusbar.showMessage(f"Deleted {deleted_count} files!") |
| 983 | + self.main_window.statusbar.repaint() |
| 984 | + |
| 985 | + def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int: |
| 986 | + """A confirmation dialogue box for deleting files. |
| 987 | +
|
| 988 | + Args: |
| 989 | + count(int): The number of files to be deleted. |
| 990 | + filename(Path | None): The filename to show if only one file is to be deleted. |
| 991 | + """ |
| 992 | + trash_term: str = "Trash" |
| 993 | + if platform.system == "Windows": |
| 994 | + trash_term = "Recycle Bin" |
| 995 | + # NOTE: Windows + send2trash will PERMANENTLY delete files which cannot be moved to the |
| 996 | + # Recycle Bin. This is done without any warning, so this message is currently the |
| 997 | + # best way I've got to inform the user. |
| 998 | + # https://github.com/arsenetar/send2trash/issues/28 |
| 999 | + # This warning is applied to all platforms until at least macOS and Linux can be verified |
| 1000 | + # to not exhibit this same behavior. |
| 1001 | + perm_warning: str = ( |
| 1002 | + f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, UiColor.RED)}'>" |
| 1003 | + f"<b>WARNING!</b> If this file can't be moved to the {trash_term}, " |
| 1004 | + f"</b>it will be <b>permanently deleted!</b></h4>" |
| 1005 | + ) |
| 1006 | + |
| 1007 | + msg = QMessageBox() |
| 1008 | + msg.setTextFormat(Qt.TextFormat.RichText) |
| 1009 | + msg.setWindowTitle("Delete File" if count == 1 else "Delete Files") |
| 1010 | + msg.setIcon(QMessageBox.Icon.Warning) |
| 1011 | + if count <= 1: |
| 1012 | + msg.setText( |
| 1013 | + f"<h3>Are you sure you want to move this file to the {trash_term}?</h3>" |
| 1014 | + "<h4>This will remove it from TagStudio <i>AND</i> your file system!</h4>" |
| 1015 | + f"{filename if filename else ''}" |
| 1016 | + f"{perm_warning}<br>" |
| 1017 | + ) |
| 1018 | + elif count > 1: |
| 1019 | + msg.setText( |
| 1020 | + f"<h3>Are you sure you want to move these {count} files to the {trash_term}?</h3>" |
| 1021 | + "<h4>This will remove them from TagStudio <i>AND</i> your file system!</h4>" |
| 1022 | + f"{perm_warning}<br>" |
| 1023 | + ) |
| 1024 | + |
| 1025 | + yes_button: QPushButton = msg.addButton("&Yes", QMessageBox.ButtonRole.YesRole) |
| 1026 | + msg.addButton("&No", QMessageBox.ButtonRole.NoRole) |
| 1027 | + msg.setDefaultButton(yes_button) |
| 1028 | + |
| 1029 | + return msg.exec() |
| 1030 | + |
905 | 1031 | def show_tag_manager(self):
|
906 | 1032 | self.modal = PanelModal(
|
907 | 1033 | widget=TagDatabasePanel(self.lib),
|
@@ -1412,6 +1538,9 @@ def update_thumbs(self):
|
1412 | 1538 | if not entry:
|
1413 | 1539 | continue
|
1414 | 1540 |
|
| 1541 | + with catch_warnings(record=True): |
| 1542 | + item_thumb.delete_action.triggered.disconnect() |
| 1543 | + |
1415 | 1544 | item_thumb.set_mode(ItemType.ENTRY)
|
1416 | 1545 | item_thumb.set_item_id(entry.id)
|
1417 | 1546 | item_thumb.show()
|
@@ -1457,6 +1586,11 @@ def update_thumbs(self):
|
1457 | 1586 | )
|
1458 | 1587 | )
|
1459 | 1588 | )
|
| 1589 | + item_thumb.delete_action.triggered.connect( |
| 1590 | + lambda checked=False, f=filenames[index], e_id=entry.id: self.delete_files_callback( |
| 1591 | + f, e_id |
| 1592 | + ) |
| 1593 | + ) |
1460 | 1594 |
|
1461 | 1595 | # Restore Selected Borders
|
1462 | 1596 | is_selected = item_thumb.item_id in self.selected
|
|
0 commit comments