Skip to content

Commit b6e0efe

Browse files
committed
feat: port file trashing (#409) to sql
1 parent 26d3b19 commit b6e0efe

File tree

6 files changed

+201
-1
lines changed

6 files changed

+201
-1
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.
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+
"""Sends 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

tagstudio/src/qt/ts_qt.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@
77

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

10+
import contextlib
1011
import ctypes
1112
import dataclasses
1213
import math
1314
import os
15+
import platform
1416
import re
1517
import sys
1618
import time
1719
from pathlib import Path
1820
from queue import Queue
21+
from warnings import catch_warnings
1922

2023
# this import has side-effect of import PySide resources
2124
import src.qt.resources_rc # noqa: F401
@@ -66,13 +69,15 @@
6669
from src.core.library.alchemy.fields import _FieldID
6770
from src.core.library.alchemy.library import Entry, LibraryStatus
6871
from src.core.media_types import MediaCategories
72+
from src.core.palette import ColorType, UiColor, get_ui_color
6973
from src.core.query_lang.util import ParsingError
7074
from src.core.ts_core import TagStudioCore
7175
from src.core.utils.refresh_dir import RefreshDirTracker
7276
from src.core.utils.web import strip_web_protocol
7377
from src.qt.cache_manager import CacheManager
7478
from src.qt.flowlayout import FlowLayout
7579
from src.qt.helpers.custom_runnable import CustomRunnable
80+
from src.qt.helpers.file_deleter import delete_file
7681
from src.qt.helpers.function_iterator import FunctionIterator
7782
from src.qt.main_window import Ui_MainWindow
7883
from src.qt.modals.about import AboutModal
@@ -483,6 +488,13 @@ def start(self) -> None:
483488

484489
edit_menu.addSeparator()
485490

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+
486498
self.manage_file_ext_action = QAction(menu_bar)
487499
Translations.translate_qobject(
488500
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]):
902914
for entry_id in self.selected:
903915
self.lib.add_tags_to_entry(entry_id, tag_ids)
904916

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+
9051031
def show_tag_manager(self):
9061032
self.modal = PanelModal(
9071033
widget=TagDatabasePanel(self.lib),
@@ -1412,6 +1538,9 @@ def update_thumbs(self):
14121538
if not entry:
14131539
continue
14141540

1541+
with catch_warnings(record=True):
1542+
item_thumb.delete_action.triggered.disconnect()
1543+
14151544
item_thumb.set_mode(ItemType.ENTRY)
14161545
item_thumb.set_item_id(entry.id)
14171546
item_thumb.show()
@@ -1457,6 +1586,11 @@ def update_thumbs(self):
14571586
)
14581587
)
14591588
)
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+
)
14601594

14611595
# Restore Selected Borders
14621596
is_selected = item_thumb.item_id in self.selected

tagstudio/src/qt/widgets/item_thumb.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
22
# Licensed under the GPL-3.0 License.
33
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
4+
import platform
45
import time
56
import typing
67
from enum import Enum
@@ -221,8 +222,15 @@ def __init__(
221222
open_file_action.triggered.connect(self.opener.open_file)
222223
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
223224
open_explorer_action.triggered.connect(self.opener.open_explorer)
225+
226+
trash_term: str = "Trash"
227+
if platform.system() == "Windows":
228+
trash_term = "Recycle Bin"
229+
self.delete_action: QAction = QAction(f"Send file to {trash_term}", self)
230+
224231
self.thumb_button.addAction(open_file_action)
225232
self.thumb_button.addAction(open_explorer_action)
233+
self.thumb_button.addAction(self.delete_action)
226234

227235
# Static Badges ========================================================
228236

tagstudio/src/qt/widgets/preview/preview_thumb.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
44

55
import io
6+
import platform
67
import time
78
import typing
89
from pathlib import Path
10+
from warnings import catch_warnings
911

1012
import cv2
1113
import rawpy
@@ -25,6 +27,7 @@
2527
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
2628
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
2729
from src.qt.platform_strings import PlatformStrings
30+
from src.qt.resource_manager import ResourceManager
2831
from src.qt.translations import Translations
2932
from src.qt.widgets.media_player import MediaPlayer
3033
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -55,24 +58,31 @@ def __init__(self, library: Library, driver: "QtDriver"):
5558
self.open_file_action = QAction(self)
5659
Translations.translate_qobject(self.open_file_action, "file.open_file")
5760
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
61+
self.trash_term: str = "Trash"
62+
if platform.system() == "Windows":
63+
self.trash_term = "Recycle Bin"
64+
self.delete_action = QAction(f"Send file to {self.trash_term}", self)
5865

5966
self.preview_img = QPushButtonWrapper()
6067
self.preview_img.setMinimumSize(*self.img_button_size)
6168
self.preview_img.setFlat(True)
6269
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
6370
self.preview_img.addAction(self.open_file_action)
6471
self.preview_img.addAction(self.open_explorer_action)
72+
self.preview_img.addAction(self.delete_action)
6573

6674
self.preview_gif = QLabel()
6775
self.preview_gif.setMinimumSize(*self.img_button_size)
6876
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
6977
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
7078
self.preview_gif.addAction(self.open_file_action)
7179
self.preview_gif.addAction(self.open_explorer_action)
80+
self.preview_gif.addAction(self.delete_action)
7281
self.preview_gif.hide()
7382
self.gif_buffer: QBuffer = QBuffer()
7483

7584
self.preview_vid = VideoPlayer(driver)
85+
self.preview_vid.addAction(self.delete_action)
7686
self.preview_vid.hide()
7787
self.thumb_renderer = ThumbRenderer(self.lib)
7888
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:
355365
update_on_ratio_change=True,
356366
)
357367

358-
if self.preview_img.is_connected:
368+
with catch_warnings(record=True):
359369
self.preview_img.clicked.disconnect()
360370
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
361371
self.preview_img.is_connected = True
@@ -367,12 +377,29 @@ def update_preview(self, filepath: Path, ext: str) -> dict:
367377
self.open_file_action.triggered.connect(self.opener.open_file)
368378
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
369379

380+
with catch_warnings(record=True):
381+
self.delete_action.triggered.disconnect()
382+
383+
self.delete_action.setText(f"Send file to {self.trash_term}")
384+
self.delete_action.triggered.connect(
385+
lambda checked=False, f=filepath: self.driver.delete_files_callback(f)
386+
)
387+
self.delete_action.setEnabled(bool(filepath))
388+
370389
return stats
371390

372391
def hide_preview(self):
373392
"""Completely hide the file preview."""
374393
self.switch_preview("")
375394

395+
def stop_file_use(self):
396+
"""Stops the use of the currently previewed file. Used to release file permissions."""
397+
logger.info("[PreviewThumb] Stopping file use in video playback...")
398+
# This swaps the video out for a placeholder so the previous video's file
399+
# is no longer in use by this object.
400+
self.preview_vid.play(str(ResourceManager.get_path("placeholder_mp4")), QSize(8, 8))
401+
self.preview_vid.hide()
402+
376403
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
377404
self.update_image_size((self.size().width(), self.size().height()))
378405
return super().resizeEvent(event)

0 commit comments

Comments
 (0)