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
This commit is contained in:
Travis Abendshien
2025-02-05 19:25:10 -08:00
committed by GitHub
parent 466af1e6a6
commit a3df70bb8d
9 changed files with 271 additions and 15 deletions

View File

@@ -12,6 +12,7 @@ PySide6_Addons==6.8.0.1
PySide6_Essentials==6.8.0.1
PySide6==6.8.0.1
rawpy==0.22.0
Send2Trash==1.8.3
SQLAlchemy==2.0.34
structlog==24.4.0
typing_extensions>=3.10.0.0,<=4.11.0

Binary file not shown.

View File

@@ -157,6 +157,9 @@
"macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...",
"macros.running.dialog.title": "Running Macros on New Entries",
"media_player.autoplay": "Autoplay",
"menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}",
"menu.delete_selected_files_plural": "Move Files to {trash_term}",
"menu.delete_selected_files_singular": "Move File to {trash_term}",
"menu.edit.ignore_list": "Ignore Files and Folders",
"menu.edit.manage_file_extensions": "Manage File Extensions",
"menu.edit.manage_tags": "Manage Tags",
@@ -195,6 +198,11 @@
"sorting.direction.ascending": "Ascending",
"sorting.direction.descending": "Descending",
"splash.opening_library": "Opening Library \"{library_path}\"...",
"status.deleted_file_plural": "Deleted {count} files!",
"status.deleted_file_singular": "Deleted 1 file!",
"status.deleted_none": "No files deleted.",
"status.deleted_partial_warning": "Only deleted {count} file(s)! Check if any of the files are currently missing or in use.",
"status.deleting_file": "Deleting file [{i}/{count}]: \"{path}\"...",
"status.library_backup_in_progress": "Saving Library Backup...",
"status.library_backup_success": "Library Backup Saved at: \"{path}\" ({time_span})",
"status.library_closed": "Library Closed ({time_span})",
@@ -230,6 +238,18 @@
"tag.shorthand": "Shorthand",
"tag.tag_name_required": "Tag Name (Required)",
"tag.view_limit": "View Limit:",
"trash.context.ambiguous": "Move file(s) to {trash_term}",
"trash.context.plural": "Move files to {trash_term}",
"trash.context.singular": "Move file to {trash_term}",
"trash.dialog.disambiguation_warning.plural": "This will remove them from TagStudio <i>AND</i> your file system!",
"trash.dialog.disambiguation_warning.singular": "This will remove it from TagStudio <i>AND</i> your file system!",
"trash.dialog.move.confirmation.plural": "Are you sure you want to move these {count} files to the {trash_term}?",
"trash.dialog.move.confirmation.singular": "Are you sure you want to move this file to the {trash_term}?",
"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>",
"trash.dialog.title.plural": "Delete Files",
"trash.dialog.title.singular": "Delete File",
"trash.name.generic": "Trash",
"trash.name.windows": "Recycle Bin",
"view.size.0": "Mini",
"view.size.1": "Small",
"view.size.2": "Medium",

View File

@@ -0,0 +1,30 @@
# Copyright (C) 2025 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:
"""Send 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

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -9,10 +9,17 @@ import platform
from src.qt.translations import Translations
class PlatformStrings:
open_file_str: str = Translations["file.open_location.generic"]
def open_file_str() -> str:
if platform.system() == "Windows":
open_file_str = Translations["file.open_location.windows"]
return Translations["file.open_location.windows"]
elif platform.system() == "Darwin":
open_file_str = Translations["file.open_location.mac"]
return Translations["file.open_location.mac"]
else:
return Translations["file.open_location.generic"]
def trash_term() -> str:
if platform.system() == "Windows":
return Translations["trash.name.windows"]
else:
return Translations["trash.name.generic"]

View File

@@ -7,6 +7,7 @@
"""A Qt driver for TagStudio."""
import contextlib
import ctypes
import dataclasses
import math
@@ -67,6 +68,7 @@ from src.core.library.alchemy.enums import (
from src.core.library.alchemy.fields import _FieldID
from src.core.library.alchemy.library import Entry, LibraryStatus
from src.core.media_types import MediaCategories
from src.core.palette import ColorType, UiColor, get_ui_color
from src.core.query_lang.util import ParsingError
from src.core.ts_core import TagStudioCore
from src.core.utils.refresh_dir import RefreshDirTracker
@@ -74,6 +76,7 @@ from src.core.utils.web import strip_web_protocol
from src.qt.cache_manager import CacheManager
from src.qt.flowlayout import FlowLayout
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.file_deleter import delete_file
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.main_window import Ui_MainWindow
from src.qt.modals.about import AboutModal
@@ -86,6 +89,7 @@ from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
from src.qt.modals.folders_to_tags import FoldersToTagsModal
from src.qt.modals.tag_database import TagDatabasePanel
from src.qt.modals.tag_search import TagSearchPanel
from src.qt.platform_strings import trash_term
from src.qt.resource_manager import ResourceManager
from src.qt.splash import Splash
from src.qt.translations import Translations
@@ -498,6 +502,17 @@ class QtDriver(DriverMixin, QObject):
edit_menu.addSeparator()
self.delete_file_action = QAction(menu_bar)
Translations.translate_qobject(
self.delete_file_action, "menu.delete_selected_files_ambiguous", trash_term=trash_term()
)
self.delete_file_action.triggered.connect(lambda f="": self.delete_files_callback(f))
self.delete_file_action.setShortcut(QtCore.Qt.Key.Key_Delete)
self.delete_file_action.setEnabled(False)
edit_menu.addAction(self.delete_file_action)
edit_menu.addSeparator()
self.manage_file_ext_action = QAction(menu_bar)
Translations.translate_qobject(
self.manage_file_ext_action, "menu.edit.manage_file_extensions"
@@ -839,10 +854,13 @@ class QtDriver(DriverMixin, QObject):
self.main_window.setWindowTitle(self.base_title)
self.selected = []
self.frame_content = []
self.selected.clear()
self.frame_content.clear()
[x.set_mode(None) for x in self.item_thumbs]
self.set_clipboard_menu_viability()
self.set_select_actions_visibility()
self.preview_panel.update_widgets()
self.main_window.toggle_landing_page(enabled=True)
self.main_window.pagination.setHidden(True)
@@ -937,6 +955,141 @@ class QtDriver(DriverMixin, QObject):
for entry_id in self.selected:
self.lib.add_tags_to_entry(entry_id, tag_ids)
def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None):
"""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.
origin_id(id): The entry ID associated with the widget making the call.
"""
entry: Entry | None = None
pending: list[tuple[int, Path]] = []
deleted_count: int = 0
if len(self.selected) <= 1 and origin_path:
origin_id_ = origin_id
if not origin_id_:
with contextlib.suppress(IndexError):
origin_id_ = self.selected[0]
pending.append((origin_id_, Path(origin_path)))
elif (len(self.selected) > 1) or (len(self.selected) <= 1):
for item in self.selected:
entry = self.lib.get_entry(item)
filepath: Path = entry.path
pending.append((item, filepath))
if pending:
return_code = self.delete_file_confirmation(len(pending), pending[0][1])
# If there was a confirmation and not a cancellation
if (
return_code == QMessageBox.ButtonRole.DestructiveRole.value
and return_code != QMessageBox.ButtonRole.ActionRole.value
):
for i, tup in enumerate(pending):
e_id, f = tup
if (origin_path == f) or (not origin_path):
self.preview_panel.thumb.stop_file_use()
if delete_file(self.lib.library_dir / f):
self.main_window.statusbar.showMessage(
Translations.translate_formatted(
"status.deleting_file", i=i, count=len(pending), path=f
)
)
self.main_window.statusbar.repaint()
self.lib.remove_entries([e_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(Translations["status.deleted_none"])
elif len(self.selected) <= 1 and deleted_count == 1:
self.main_window.statusbar.showMessage(
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
)
elif len(self.selected) > 1 and deleted_count == 0:
self.main_window.statusbar.showMessage(Translations["status.deleted_none"])
elif len(self.selected) > 1 and deleted_count < len(self.selected):
self.main_window.statusbar.showMessage(
Translations.translate_formatted(
"status.deleted_partial_warning", count=deleted_count
)
)
elif len(self.selected) > 1 and deleted_count == len(self.selected):
self.main_window.statusbar.showMessage(
Translations.translate_formatted("status.deleted_file_plural", count=deleted_count)
)
self.main_window.statusbar.repaint()
def delete_file_confirmation(self, count: int, filename: Path | None = None) -> int:
"""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.
"""
# 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_msg = Translations.translate_formatted(
"trash.dialog.permanent_delete_warning", trash_term=trash_term()
)
perm_warning: str = (
f"<h4 style='color: {get_ui_color(ColorType.PRIMARY, UiColor.RED)}'>"
f"{perm_warning_msg}</h4>"
)
msg = QMessageBox()
msg.setStyleSheet("font-weight:normal;")
msg.setTextFormat(Qt.TextFormat.RichText)
msg.setWindowTitle(
Translations["trash.title.singular"]
if count == 1
else Translations["trash.title.plural"]
)
msg.setIcon(QMessageBox.Icon.Warning)
if count <= 1:
msg_text = Translations.translate_formatted(
"trash.dialog.move.confirmation.singular", trash_term=trash_term()
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations["trash.dialog.disambiguation_warning.singular"]}</h4>"
f"{filename if filename else ''}"
f"{perm_warning}<br>"
)
elif count > 1:
msg_text = Translations.translate_formatted(
"trash.dialog.move.confirmation.plural",
count=count,
trash_term=trash_term(),
)
msg.setText(
f"<h3>{msg_text}</h3>"
f"<h4>{Translations["trash.dialog.disambiguation_warning.plural"]}</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):
"""Run when user initiates adding new files to the Library."""
tracker = RefreshDirTracker(self.lib)
@@ -1315,9 +1468,11 @@ class QtDriver(DriverMixin, QObject):
if self.selected:
self.add_tag_to_selected_action.setEnabled(True)
self.clear_select_action.setEnabled(True)
self.delete_file_action.setEnabled(True)
else:
self.add_tag_to_selected_action.setEnabled(False)
self.clear_select_action.setEnabled(False)
self.delete_file_action.setEnabled(False)
def update_completions_list(self, text: str) -> None:
matches = re.search(
@@ -1425,6 +1580,9 @@ class QtDriver(DriverMixin, QObject):
if not entry:
continue
with catch_warnings(record=True):
item_thumb.delete_action.triggered.disconnect()
item_thumb.set_mode(ItemType.ENTRY)
item_thumb.set_item_id(entry.id)
item_thumb.show()
@@ -1470,6 +1628,11 @@ class QtDriver(DriverMixin, QObject):
)
)
)
item_thumb.delete_action.triggered.connect(
lambda checked=False, f=filenames[index], e_id=entry.id: self.delete_files_callback(
f, e_id
)
)
# Restore Selected Borders
is_selected = item_thumb.item_id in self.selected

View File

@@ -29,7 +29,7 @@ from src.core.library import ItemType, Library
from src.core.media_types import MediaCategories, MediaType
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str, trash_term
from src.qt.translations import Translations
from src.qt.widgets.thumb_button import ThumbButton
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -219,10 +219,17 @@ class ItemThumb(FlowWidget):
open_file_action = QAction(self)
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.delete_action = QAction(self)
Translations.translate_qobject(
self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
)
self.thumb_button.addAction(open_file_action)
self.thumb_button.addAction(open_explorer_action)
self.thumb_button.addAction(self.delete_action)
# Static Badges ========================================================

View File

@@ -6,6 +6,7 @@ import io
import time
import typing
from pathlib import Path
from warnings import catch_warnings
import cv2
import rawpy
@@ -24,7 +25,8 @@ from src.qt.helpers.file_opener import FileOpenerHelper, open_file
from src.qt.helpers.file_tester import is_readable_video
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str, trash_term
from src.qt.resource_manager import ResourceManager
from src.qt.translations import Translations
from src.qt.widgets.media_player import MediaPlayer
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -54,7 +56,11 @@ class PreviewThumb(QWidget):
self.open_file_action = QAction(self)
Translations.translate_qobject(self.open_file_action, "file.open_file")
self.open_explorer_action = QAction(PlatformStrings.open_file_str, self)
self.open_explorer_action = QAction(open_file_str(), self)
self.delete_action = QAction(self)
Translations.translate_qobject(
self.delete_action, "trash.context.ambiguous", trash_term=trash_term()
)
self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
@@ -62,6 +68,7 @@ class PreviewThumb(QWidget):
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.preview_img.addAction(self.delete_action)
self.preview_gif = QLabel()
self.preview_gif.setMinimumSize(*self.img_button_size)
@@ -69,10 +76,12 @@ class PreviewThumb(QWidget):
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
self.preview_gif.addAction(self.open_file_action)
self.preview_gif.addAction(self.open_explorer_action)
self.preview_gif.addAction(self.delete_action)
self.preview_gif.hide()
self.gif_buffer: QBuffer = QBuffer()
self.preview_vid = VideoPlayer(driver)
self.preview_vid.addAction(self.delete_action)
self.preview_vid.hide()
self.thumb_renderer = ThumbRenderer(self.lib)
self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
@@ -355,7 +364,7 @@ class PreviewThumb(QWidget):
update_on_ratio_change=True,
)
if self.preview_img.is_connected:
with catch_warnings(record=True):
self.preview_img.clicked.disconnect()
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
self.preview_img.is_connected = True
@@ -367,12 +376,31 @@ class PreviewThumb(QWidget):
self.open_file_action.triggered.connect(self.opener.open_file)
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
with catch_warnings(record=True):
self.delete_action.triggered.disconnect()
self.delete_action.setText(
Translations.translate_formatted("trash.context.singular", trash_term=trash_term())
)
self.delete_action.triggered.connect(
lambda checked=False, f=filepath: self.driver.delete_files_callback(f)
)
self.delete_action.setEnabled(bool(filepath))
return stats
def hide_preview(self):
"""Completely hide the file preview."""
self.switch_preview("")
def stop_file_use(self):
"""Stops the use of the currently previewed file. Used to release file permissions."""
logger.info("[PreviewThumb] Stopping file use in video playback...")
# This swaps the video out for a placeholder so the previous video's file
# is no longer in use by this object.
self.preview_vid.play(str(ResourceManager.get_path("placeholder_mp4")), QSize(8, 8))
self.preview_vid.hide()
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
self.update_image_size((self.size().width(), self.size().height()))
return super().resizeEvent(event)

View File

@@ -30,7 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView
from src.core.enums import SettingItems
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.platform_strings import PlatformStrings
from src.qt.platform_strings import open_file_str
from src.qt.translations import Translations
if typing.TYPE_CHECKING:
@@ -130,7 +130,7 @@ class VideoPlayer(QGraphicsView):
Translations.translate_qobject(open_file_action, "file.open_file")
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action = QAction(open_file_str(), self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
self.addAction(open_file_action)