Skip to content

Commit

Permalink
feat: port file trashing (#409) to v9.5 (#792)
Browse files Browse the repository at this point in the history
* feat: port file trashing (#409) to sql

* translations: translate file deletion actions

* fix: rename method from refactor conflict

* refactor: implement feedback
  • Loading branch information
CyanVoxel authored Feb 6, 2025
1 parent 466af1e commit a3df70b
Show file tree
Hide file tree
Showing 9 changed files with 271 additions and 15 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added tagstudio/resources/qt/videos/placeholder.mp4
Binary file not shown.
20 changes: 20 additions & 0 deletions tagstudio/resources/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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})",
Expand Down Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions tagstudio/src/qt/helpers/file_deleter.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 13 additions & 6 deletions tagstudio/src/qt/platform_strings.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -9,10 +9,17 @@
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"]
167 changes: 165 additions & 2 deletions tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

"""A Qt driver for TagStudio."""

import contextlib
import ctypes
import dataclasses
import math
Expand Down Expand Up @@ -67,13 +68,15 @@
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
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
Expand All @@ -86,6 +89,7 @@
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
Expand Down Expand Up @@ -498,6 +502,17 @@ def start(self) -> None:

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"
Expand Down Expand Up @@ -839,10 +854,13 @@ def close_library(self, is_shutdown: bool = False):

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)
Expand Down Expand Up @@ -937,6 +955,141 @@ def add_tags_to_selected_callback(self, tag_ids: list[int]):
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)
Expand Down Expand Up @@ -1315,9 +1468,11 @@ def set_select_actions_visibility(self):
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(
Expand Down Expand Up @@ -1425,6 +1580,9 @@ def update_thumbs(self):
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()
Expand Down Expand Up @@ -1470,6 +1628,11 @@ def update_thumbs(self):
)
)
)
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
Expand Down
11 changes: 9 additions & 2 deletions tagstudio/src/qt/widgets/item_thumb.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
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
Expand Down Expand Up @@ -219,10 +219,17 @@ def __init__(
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 ========================================================

Expand Down
Loading

0 comments on commit a3df70b

Please sign in to comment.