feat: send deleted files to system trash

This refactors the file deletion code to send files to the system trash instead of performing a hard deletion. It also fixes deleting video files and GIFs loaded in the Preview Panel.
This commit is contained in:
Travis Abendshien
2024-08-26 21:07:22 -07:00
parent 39fcc65bcb
commit 91252ce710
10 changed files with 230 additions and 54 deletions

View File

@@ -14,3 +14,4 @@ pydub==0.25.1
mutagen==1.47.0
numpy==1.26.4
ffmpeg-python==0.2.0
Send2Trash==1.8.3

Binary file not shown.

View File

@@ -23,6 +23,7 @@ class MediaType(str, Enum):
DISK_IMAGE: str = "disk_image"
DOCUMENT: str = "document"
FONT: str = "font"
IMAGE_ANIMATED: str = "image_animated"
IMAGE_RAW: str = "image_raw"
IMAGE_VECTOR: str = "image_vector"
IMAGE: str = "image"
@@ -168,6 +169,12 @@ class MediaCategories:
".woff",
".woff2",
}
_IMAGE_ANIMATED_SET: set[str] = {
".apng",
".gif",
".webp",
".jxl",
}
_IMAGE_RAW_SET: set[str] = {
".arw",
".cr2",
@@ -317,6 +324,11 @@ class MediaCategories:
extensions=_FONT_SET,
is_iana=True,
)
IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_ANIMATED,
extensions=_IMAGE_ANIMATED_SET,
is_iana=False,
)
IMAGE_RAW_TYPES: MediaCategory = MediaCategory(
media_type=MediaType.IMAGE_RAW,
extensions=_IMAGE_RAW_SET,
@@ -404,6 +416,7 @@ class MediaCategories:
DISK_IMAGE_TYPES,
DOCUMENT_TYPES,
FONT_TYPES,
IMAGE_ANIMATED_TYPES,
IMAGE_RAW_TYPES,
IMAGE_TYPES,
IMAGE_VECTOR_TYPES,

View File

@@ -1,38 +1,30 @@
import logging
import traceback
from pathlib import Path
from collections.abc import Callable
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
import logging
from pathlib import Path
from send2trash import send2trash
logging.basicConfig(format="%(message)s", level=logging.INFO)
def delete_file(path: str | Path, callback: Callable):
_path = str(path)
_file = Path(_path)
logging.info(f"Deleting file: {_path}")
if not _file.exists():
logging.error(f"File not found: {_path}")
return
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:
_file.unlink()
callback()
except Exception as exception:
logging.exception(exception)
class FileDeleterHelper:
def __init__(self, filepath: str | Path):
self.filepath = filepath
def set_filepath(self, filepath: str | Path):
self.filepath = filepath
def set_delete_callback(self, callback: Callable):
self.delete_callback = callback
def delete_file(self):
delete_file(self.filepath, self.delete_callback)
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

@@ -31,6 +31,20 @@ class ResourceManager:
)
ResourceManager._initialized = True
@staticmethod
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

View File

@@ -90,5 +90,9 @@
"thumb_loading": {
"path": "qt/images/thumb_loading.png",
"mode": "pil"
},
"placeholder_mp4": {
"path": "qt/videos/placeholder.mp4",
"mode": "rb"
}
}

View File

@@ -69,9 +69,11 @@ from src.core.constants import (
TAG_FAVORITE,
TAG_ARCHIVED,
)
from src.core.media_types import MediaCategories, MediaType
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
@@ -532,7 +534,7 @@ class QtDriver(QObject):
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")
@@ -549,6 +551,9 @@ class QtDriver(QObject):
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)
@@ -824,6 +829,73 @@ class QtDriver(QObject):
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.
"""
_op: Path = Path(origin_path)
item = None
deleted_count: int = 0
filepath: Path = None # Initialize
if len(self.selected) <= 1:
if self.selected:
item = self.lib.get_entry(self.selected[0][1])
filepath = self.lib.library_dir / item.path / item.filename
# If the file to be deleted is currently being displayed on the Preview Panel,
# tell the panel to stop any use of the file.
if origin_path == filepath:
self.preview_panel.stop_file_use()
self.main_window.statusbar.showMessage(f'Deleting file "{origin_path}"...')
self.main_window.statusbar.repaint()
if delete_file(_op):
op_item = self.lib.get_entry_id_from_filepath(_op)
self.lib.remove_entry(op_item)
self.purge_item_from_navigation(ItemType.ENTRY, op_item)
deleted_count += 1
elif len(self.selected) > 1:
for i, item_pair in enumerate(self.selected):
if item_pair[0] == ItemType.ENTRY:
item = self.lib.get_entry(item_pair[1])
filepath = self.lib.library_dir / item.path / item.filename
self.main_window.statusbar.showMessage(
f'Deleting file "{filepath}"...'
)
self.main_window.statusbar.repaint()
if delete_file(filepath):
self.purge_item_from_navigation(item.type, item.id)
self.lib.remove_entry(item.id)
deleted_count += 1
self.selected.clear()
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. Check if any of the files are currently in use."
)
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! Check if any of the files are currently in use."
)
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 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 add_new_files_callback(self):
"""Runs when user initiates adding new files to the Library."""
# # if self.lib.files_not_in_library:
@@ -1449,12 +1521,20 @@ class QtDriver(QObject):
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
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))
@@ -1499,7 +1579,9 @@ class QtDriver(QObject):
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=(

View File

@@ -132,6 +132,7 @@ class FieldContainer(QWidget):
def set_copy_callback(self, callback: Optional[MethodType]):
if self.copy_button.is_connected:
self.copy_button.clicked.disconnect()
self.copy_button.is_connected = False
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
@@ -141,6 +142,7 @@ class FieldContainer(QWidget):
def set_edit_callback(self, callback: Optional[MethodType]):
if self.edit_button.is_connected:
self.edit_button.clicked.disconnect()
self.edit_button.is_connected = False
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
@@ -150,10 +152,12 @@ class FieldContainer(QWidget):
def set_remove_callback(self, callback: Optional[Callable]):
if self.remove_button.is_connected:
self.remove_button.clicked.disconnect()
self.remove_button.is_connected = False
self.remove_callback = callback
self.remove_button.clicked.connect(callback)
self.remove_button.is_connected = True
if callback is not None:
self.remove_button.is_connected = True
def set_inner_widget(self, widget: "FieldWidget"):
# widget.setStyleSheet('background-color:green;')

View File

@@ -8,6 +8,7 @@ import time
import typing
from pathlib import Path
from typing import Optional
import platform
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QSize, QEvent, QMimeData, QUrl
@@ -30,7 +31,6 @@ from src.core.constants import (
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.helpers.file_deleter import FileDeleterHelper
from src.qt.widgets.thumb_renderer import ThumbRenderer
from src.qt.widgets.thumb_button import ThumbButton
@@ -194,16 +194,19 @@ class ItemThumb(FlowWidget):
self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper("")
self.deleter = FileDeleterHelper("")
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction("Open file in explorer", self)
open_explorer_action.triggered.connect(self.opener.open_explorer)
delete_action = QAction("Delete", self)
delete_action.triggered.connect(self.deleter.delete_file)
trash_term: str = "Trash"
if platform.system() == "Windows":
trash_term = "Recycle Bin"
self.delete_action = 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(delete_action)
self.thumb_button.addAction(self.delete_action)
# Static Badges ========================================================
@@ -445,8 +448,6 @@ class ItemThumb(FlowWidget):
entry = self.lib.get_entry(self.item_id)
filepath = self.lib.library_dir / entry.path / entry.filename
self.opener.set_filepath(filepath)
self.deleter.set_filepath(filepath)
self.deleter.set_delete_callback(self._on_delete)
def assign_favorite(self, value: bool):
# Switching mode to None to bypass mode-specific operations when the
@@ -547,9 +548,3 @@ class ItemThumb(FlowWidget):
mimedata.setUrls(paths)
drag.setMimeData(mimedata)
drag.exec(Qt.DropAction.CopyAction)
def _on_delete(self):
entry = self.lib.get_entry(self.item_id)
self.lib.remove_entry(self.item_id)
self.panel.driver.purge_item_from_navigation(entry.type, self.item_id)
self.panel.driver.filter_items()

View File

@@ -4,6 +4,7 @@
import logging
from pathlib import Path
import platform
import time
import typing
from datetime import datetime as dt
@@ -11,7 +12,7 @@ import cv2
import rawpy
from PIL import Image, UnidentifiedImageError, ImageFont
from PIL.Image import DecompressionBombError
from PySide6.QtCore import QModelIndex, Signal, Qt, QSize
from PySide6.QtCore import QModelIndex, Signal, Qt, QSize, QByteArray, QBuffer
from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie
from PySide6.QtWidgets import (
QWidget,
@@ -45,6 +46,7 @@ from src.qt.widgets.text_line_edit import EditTextLine
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
from src.qt.widgets.video_player import VideoPlayer
from src.qt.helpers.file_tester import is_readable_video
from src.qt.resource_manager import ResourceManager
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -98,6 +100,10 @@ class PreviewPanel(QWidget):
self.open_file_action = QAction("Open file", self)
self.open_explorer_action = QAction("Open file in explorer", 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)
@@ -105,6 +111,7 @@ class PreviewPanel(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)
@@ -112,10 +119,13 @@ class PreviewPanel(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.hide()
self.preview_vid.addAction(self.delete_action)
self.thumb_renderer = ThumbRenderer()
self.thumb_renderer.updated.connect(
lambda ts, i, s: (self.preview_img.setIcon(i))
@@ -490,6 +500,14 @@ class PreviewPanel(QWidget):
)
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
self.preview_img.is_connected = False
try:
self.delete_action.triggered.disconnect()
except RuntimeWarning:
pass
self.delete_action.setEnabled(False)
for i, c in enumerate(self.containers):
c.setHidden(True)
self.preview_img.show()
@@ -529,6 +547,17 @@ class PreviewPanel(QWidget):
)
self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
try:
self.delete_action.triggered.disconnect()
except RuntimeError:
pass
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(True)
self.opener = FileOpenerHelper(filepath)
self.open_file_action.triggered.connect(self.opener.open_file)
self.open_explorer_action.triggered.connect(
@@ -539,16 +568,24 @@ class PreviewPanel(QWidget):
ext: str = filepath.suffix.lower()
try:
if filepath.suffix.lower() in [".gif"]:
movie = QMovie(str(filepath))
with open(filepath, mode="rb") as f:
if self.preview_gif.movie():
self.preview_gif.movie().stop()
self.gif_buffer.close()
ba = f.read()
self.gif_buffer.setData(ba)
movie = QMovie(self.gif_buffer, QByteArray())
self.preview_gif.setMovie(movie)
movie.start()
image = Image.open(str(filepath))
self.preview_gif.setMovie(movie)
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
)
)
movie.start()
self.preview_img.hide()
self.preview_vid.hide()
self.preview_gif.show()
@@ -660,6 +697,7 @@ class PreviewPanel(QWidget):
# TODO: Implement a clickable label to use for the GIF preview.
if self.preview_img.is_connected:
self.preview_img.clicked.disconnect()
self.preview_img.is_connected = False
self.preview_img.clicked.connect(
lambda checked=False, filepath=filepath: open_file(filepath)
)
@@ -701,6 +739,16 @@ class PreviewPanel(QWidget):
)
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
try:
self.delete_action.triggered.disconnect()
except RuntimeError:
pass
self.delete_action.setText(f"Send files to {self.trash_term}")
self.delete_action.triggered.connect(
lambda checked=False, f=None: self.driver.delete_files_callback(f)
)
self.delete_action.setEnabled(True)
ratio: float = self.devicePixelRatio()
self.thumb_renderer.render(
time.time(),
@@ -1140,3 +1188,26 @@ class PreviewPanel(QWidget):
# logging.info(result)
if result == 3:
callback()
def stop_file_use(self):
"""Stops the use of the currently previewed file. Used to release file permissions."""
logging.info("[PreviewPanel] 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(ResourceManager.get_path("placeholder_mp4"), QSize(8, 8))
self.preview_vid.hide()
# NOTE: I'm keeping this here until #357 is merged in the case it still needs to be used.
# logging.info("[PreviewPanel] Stopping file use for animated image playback...")
# logging.info(self.preview_gif.movie())
# if self.preview_gif.movie():
# self.preview_gif.movie().stop()
# with open(ResourceManager.get_path("placeholder_gif"), mode="rb") as f:
# ba = f.read()
# self.gif_buffer.setData(ba)
# movie = QMovie(self.gif_buffer, QByteArray())
# self.preview_gif.setMovie(movie)
# movie.start()
# self.preview_gif.hide()
# logging.info(self.preview_gif.movie())