-
Notifications
You must be signed in to change notification settings - Fork 381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: expanded file deletion/trashing #409
Changes from all commits
34f347b
3c27b37
7ce3519
6b892ce
ff17b93
c1cd96f
3144440
d339f86
dc135f7
10d81b3
087176e
cee4254
127fed7
32257f6
3e00a77
1529714
d2b5e31
05a4860
c582f3d
c0e56dc
598aa4f
ffdfd6c
3bfeb3c
086fc1e
ef8cc6c
ad53f10
91ee242
196c1ba
3932414
c6a5202
ad12d64
8d2e67d
6883f9e
447b5e6
c070f84
a244098
f91861d
e4f7055
387baae
81dfb50
a658fc4
ccf3d78
148f792
9f688cd
c377b9d
12d69ba
5c4a3c5
a037a3b
2796db6
dd90add
ad0f472
2faed27
69e1b20
992aa82
31ced00
bbd12b5
c196171
c44cbbb
1e23ec8
959de9b
d0ad47f
06a230f
8b9a0e9
26f28b5
43a0b93
9d03493
662b6f5
ff0f09c
26777f0
0851571
ff2153a
6f23478
b01d22a
16bcccb
3986725
94851d2
589fefa
8945d11
632e793
0e566e0
800a405
d89e120
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,4 +14,5 @@ pydub==0.25.1 | |
mutagen==1.47.0 | ||
numpy==1.26.4 | ||
ffmpeg-python==0.2.0 | ||
Send2Trash==1.8.3 | ||
vtf2img==0.1.0 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
# Copyright (C) 2024 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: | ||
"""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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -31,6 +31,20 @@ def __init__(self) -> None: | |
) | ||
ResourceManager._initialized = True | ||
|
||
@staticmethod | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar explanation here: #390 (comment) |
||
def get_path(id: str) -> Path | None: | ||
"""Get a resource's path from the ResourceManager. | ||
Args: | ||
id (str): The name of the resource. | ||
|
||
Returns: | ||
Path: The resource path if found, else None. | ||
""" | ||
res: dict = ResourceManager._map.get(id) | ||
if res: | ||
return Path(__file__).parents[2] / "resources" / res.get("path") | ||
return None | ||
|
||
def get(self, id: str) -> Any: | ||
"""Get a resource from the ResourceManager. | ||
This can include resources inside and outside of QResources, and will return | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,6 +12,7 @@ | |
import logging | ||
import math | ||
import os | ||
import platform | ||
import sys | ||
import time | ||
import typing | ||
|
@@ -53,6 +54,7 @@ | |
QMenu, | ||
QMenuBar, | ||
QComboBox, | ||
QMessageBox, | ||
) | ||
from humanfriendly import format_timespan | ||
|
||
|
@@ -69,9 +71,11 @@ | |
TAG_FAVORITE, | ||
TAG_ARCHIVED, | ||
) | ||
from src.core.palette import ColorType, get_ui_color | ||
from src.core.utils.web import strip_web_protocol | ||
from src.qt.flowlayout import FlowLayout | ||
from src.qt.main_window import Ui_MainWindow | ||
from src.qt.helpers.file_deleter import delete_file | ||
from src.qt.helpers.function_iterator import FunctionIterator | ||
from src.qt.helpers.custom_runnable import CustomRunnable | ||
from src.qt.resource_manager import ResourceManager | ||
|
@@ -446,6 +450,15 @@ 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() | ||
|
||
manage_file_extensions_action = QAction("Manage File Extensions", menu_bar) | ||
manage_file_extensions_action.triggered.connect( | ||
lambda: self.show_file_extension_modal() | ||
|
@@ -532,13 +545,13 @@ def start(self) -> None: | |
folders_to_tags_action.triggered.connect(lambda: ftt_modal.show()) | ||
macros_menu.addAction(folders_to_tags_action) | ||
|
||
# Help Menu ========================================================== | ||
# Help Menu ============================================================ | ||
self.repo_action = QAction("Visit GitHub Repository", menu_bar) | ||
self.repo_action.triggered.connect( | ||
lambda: webbrowser.open("https://github.com/TagStudioDev/TagStudio") | ||
) | ||
help_menu.addAction(self.repo_action) | ||
self.set_macro_menu_viability() | ||
self.set_menu_action_viability() | ||
|
||
self.update_clipboard_actions() | ||
|
||
|
@@ -549,6 +562,9 @@ def start(self) -> None: | |
menu_bar.addMenu(window_menu) | ||
menu_bar.addMenu(help_menu) | ||
|
||
# ====================================================================== | ||
|
||
# Preview Panel -------------------------------------------------------- | ||
self.preview_panel = PreviewPanel(self.lib, self) | ||
l: QHBoxLayout = self.main_window.splitter | ||
l.addWidget(self.preview_panel) | ||
|
@@ -758,7 +774,7 @@ def close_library(self): | |
self.copied_fields.clear() | ||
self.is_buffer_merged = False | ||
self.update_clipboard_actions() | ||
self.set_macro_menu_viability() | ||
self.set_menu_action_viability() | ||
self.preview_panel.update_widgets() | ||
self.filter_items() | ||
self.main_window.toggle_landing_page(True) | ||
|
@@ -796,15 +812,15 @@ def select_all_action_callback(self): | |
self.selected.append((item.mode, item.item_id)) | ||
item.thumb_button.set_selected(True) | ||
|
||
self.set_macro_menu_viability() | ||
self.set_menu_action_viability() | ||
self.preview_panel.update_widgets() | ||
|
||
def clear_select_action_callback(self): | ||
self.selected.clear() | ||
for item in self.item_thumbs: | ||
item.thumb_button.set_selected(False) | ||
|
||
self.set_macro_menu_viability() | ||
self.set_menu_action_viability() | ||
self.preview_panel.update_widgets() | ||
|
||
def show_tag_database(self): | ||
|
@@ -827,6 +843,114 @@ def show_file_extension_modal(self): | |
self.modal.saved.connect(lambda: (panel.save(), self.filter_items(""))) | ||
self.modal.show() | ||
|
||
def delete_files_callback(self, origin_path: str | Path): | ||
"""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. | ||
""" | ||
entry = None | ||
pending: list[Path] = [] | ||
deleted_count: int = 0 | ||
|
||
if len(self.selected) <= 1 and origin_path: | ||
pending.append(Path(origin_path)) | ||
elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not origin_path): | ||
for i, item_pair in enumerate(self.selected): | ||
if item_pair[0] == ItemType.ENTRY: | ||
entry = self.lib.get_entry(item_pair[1]) | ||
filepath: Path = self.lib.library_dir / entry.path / entry.filename | ||
pending.append(filepath) | ||
|
||
if pending: | ||
return_code = self.delete_file_confirmation(len(pending), pending[0]) | ||
logging.info(return_code) | ||
# If there was a confirmation and not a cancellation | ||
if return_code == 2 and return_code != 3: | ||
for i, f in enumerate(pending): | ||
if (origin_path == f) or (not origin_path): | ||
self.preview_panel.stop_file_use() | ||
if delete_file(f): | ||
self.main_window.statusbar.showMessage( | ||
f'Deleting file [{i}/{len(pending)}]: "{f}"...' | ||
) | ||
self.main_window.statusbar.repaint() | ||
|
||
entry_id = self.lib.get_entry_id_from_filepath(f) | ||
self.lib.remove_entry(entry_id) | ||
self.purge_item_from_navigation(ItemType.ENTRY, entry_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'}! 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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not, but should it be? |
||
"""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"<h4 style='color: {get_ui_color(ColorType.PRIMARY, 'red')}'>" | ||
f"<b>WARNING!</b> If this file can't be moved to the {trash_term}, " | ||
f"</b>it will be <b>permanently deleted!</b></h4>" | ||
) | ||
|
||
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"<h3>Are you sure you want to move this file to the {trash_term}?</h3>" | ||
"<h4>This will remove it from TagStudio <i>AND</i> your file system!</h4>" | ||
f"{filename if filename else ''}" | ||
f"{perm_warning}<br>" | ||
) | ||
elif count > 1: | ||
msg.setText( | ||
f"<h3>Are you sure you want to move these {count} files to the {trash_term}?</h3>" | ||
"<h4>This will remove them from TagStudio <i>AND</i> your file system!</h4>" | ||
f"{perm_warning}<br>" | ||
) | ||
|
||
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): | ||
"""Runs when user initiates adding new files to the Library.""" | ||
# # if self.lib.files_not_in_library: | ||
|
@@ -1397,17 +1521,19 @@ def select_item(self, type: ItemType, id: int, append: bool, bridge: bool): | |
if it.mode == type and it.item_id == id: | ||
self.preview_panel.set_tags_updated_slot(it.update_badges) | ||
|
||
self.set_macro_menu_viability() | ||
self.set_menu_action_viability() | ||
self.update_clipboard_actions() | ||
self.preview_panel.update_widgets() | ||
|
||
def set_macro_menu_viability(self): | ||
def set_menu_action_viability(self): | ||
if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0: | ||
self.autofill_action.setDisabled(True) | ||
self.sort_fields_action.setDisabled(True) | ||
self.delete_file_action.setDisabled(True) | ||
else: | ||
self.autofill_action.setDisabled(False) | ||
self.sort_fields_action.setDisabled(False) | ||
self.delete_file_action.setDisabled(False) | ||
|
||
def update_thumbs(self): | ||
"""Updates search thumbnails.""" | ||
|
@@ -1456,12 +1582,20 @@ def update_thumbs(self): | |
|
||
for i, item_thumb in enumerate(self.item_thumbs, start=0): | ||
if i < len(self.nav_frames[self.cur_frame_idx].contents): | ||
filepath = "" | ||
filepath: Path = None # Initialize | ||
if self.nav_frames[self.cur_frame_idx].contents[i][0] == ItemType.ENTRY: | ||
entry = self.lib.get_entry( | ||
self.nav_frames[self.cur_frame_idx].contents[i][1] | ||
) | ||
filepath = self.lib.library_dir / entry.path / entry.filename | ||
filepath: Path = self.lib.library_dir / entry.path / entry.filename | ||
|
||
try: | ||
item_thumb.delete_action.triggered.disconnect() | ||
except RuntimeWarning: | ||
pass | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not log this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
item_thumb.delete_action.triggered.connect( | ||
lambda checked=False, f=filepath: self.delete_files_callback(f) | ||
) | ||
|
||
item_thumb.set_item_id(entry.id) | ||
item_thumb.assign_archived(entry.has_tag(self.lib, TAG_ARCHIVED)) | ||
|
@@ -1506,7 +1640,9 @@ def update_thumbs(self): | |
else collation.e_ids_and_pages[0][0] | ||
) | ||
cover_e = self.lib.get_entry(cover_id) | ||
filepath = self.lib.library_dir / cover_e.path / cover_e.filename | ||
filepath: Path = ( | ||
self.lib.library_dir / cover_e.path / cover_e.filename | ||
) | ||
item_thumb.set_count(str(len(collation.e_ids_and_pages))) | ||
item_thumb.update_clickable( | ||
clickable=( | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have seen this in another PR, but ffmpeg needs to be installed. That should be mentioned or written how you would do that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm addressing this in #390 👍