Skip to content

Commit

Permalink
feat: port file trashing (#409) to sql
Browse files Browse the repository at this point in the history
  • Loading branch information
CyanVoxel committed Feb 5, 2025
1 parent 26d3b19 commit b6e0efe
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 1 deletion.
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.
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:
"""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
134 changes: 134 additions & 0 deletions tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@

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

import contextlib
import ctypes
import dataclasses
import math
import os
import platform
import re
import sys
import time
from pathlib import Path
from queue import Queue
from warnings import catch_warnings

# this import has side-effect of import PySide resources
import src.qt.resources_rc # noqa: F401
Expand Down Expand Up @@ -66,13 +69,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 Down Expand Up @@ -483,6 +488,13 @@ 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()

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 @@ -902,6 +914,120 @@ 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 and not origin_path):
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])
logger.info(return_code)
# If there was a confirmation and not a cancellation
if return_code == 2 and return_code != 3:
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(
f'Deleting file [{i}/{len(pending)}]: "{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("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'}! "
f"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:
"""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, UiColor.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 show_tag_manager(self):
self.modal = PanelModal(
widget=TagDatabasePanel(self.lib),
Expand Down Expand Up @@ -1412,6 +1538,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 @@ -1457,6 +1586,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
8 changes: 8 additions & 0 deletions tagstudio/src/qt/widgets/item_thumb.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import platform
import time
import typing
from enum import Enum
Expand Down Expand Up @@ -221,8 +222,15 @@ def __init__(
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction(PlatformStrings.open_file_str, self)
open_explorer_action.triggered.connect(self.opener.open_explorer)

trash_term: str = "Trash"
if platform.system() == "Windows":
trash_term = "Recycle Bin"
self.delete_action: QAction = QAction(f"Send file to {trash_term}", self)

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
29 changes: 28 additions & 1 deletion tagstudio/src/qt/widgets/preview/preview_thumb.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio

import io
import platform
import time
import typing
from pathlib import Path
from warnings import catch_warnings

import cv2
import rawpy
Expand All @@ -25,6 +27,7 @@
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.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
Expand Down Expand Up @@ -55,24 +58,31 @@ def __init__(self, library: Library, driver: "QtDriver"):
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.trash_term: str = "Trash"
if platform.system() == "Windows":
self.trash_term = "Recycle Bin"
self.delete_action = QAction(f"Send file to {self.trash_term}", self)

self.preview_img = QPushButtonWrapper()
self.preview_img.setMinimumSize(*self.img_button_size)
self.preview_img.setFlat(True)
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)
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
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)))
Expand Down Expand Up @@ -355,7 +365,7 @@ def update_preview(self, filepath: Path, ext: str) -> dict:
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
Expand All @@ -367,12 +377,29 @@ def update_preview(self, filepath: Path, ext: str) -> dict:
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(f"Send file to {self.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)

0 comments on commit b6e0efe

Please sign in to comment.