Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: expanded file deletion/trashing #409

Merged
merged 82 commits into from
Sep 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
34f347b
Fix text and RAW image handling
CyanVoxel Jun 1, 2024
3c27b37
Use chardet for character encoding detection
CyanVoxel Jun 1, 2024
7ce3519
Add support for waveform + album cover thumbnails
CyanVoxel Jun 4, 2024
6b892ce
Rename "cover" variables for MyPy
CyanVoxel Jun 4, 2024
ff17b93
Rename "audio_tags" variables for MyPy + typing
CyanVoxel Jun 4, 2024
c1cd96f
Add # type: ignore to fromstring method
CyanVoxel Jun 4, 2024
3144440
Add GIF preview support
CyanVoxel Jun 4, 2024
d339f86
Add rough check for invalid video codecs
CyanVoxel Jun 8, 2024
dc135f7
Add ".plist" to PLAINTEXT_TYPES
CyanVoxel Jun 8, 2024
10d81b3
Add readable video tester
CyanVoxel Jun 9, 2024
087176e
Add ".psd" to IMAGE_TYPES; Handle ID3NoHeaderError
CyanVoxel Jun 13, 2024
cee4254
Improve and style waveform previews
CyanVoxel Jun 15, 2024
127fed7
Add final return statement to _album_artwork()
CyanVoxel Jun 15, 2024
32257f6
Add final return statement to _audio_waveform()
CyanVoxel Jun 15, 2024
3e00a77
Tweak waveform color and size
CyanVoxel Jun 15, 2024
1529714
Fix ItemThumb label text color in light mode
CyanVoxel Jun 15, 2024
d2b5e31
Fix most theme UI legibility issues
CyanVoxel Jun 15, 2024
05a4860
Match additional UI to color scheme
CyanVoxel Jun 16, 2024
c582f3d
ruff format
CyanVoxel Jul 19, 2024
c0e56dc
feat(ui): add UI color palette dict
CyanVoxel Jul 19, 2024
598aa4f
feat(ui) center and color small font previews
CyanVoxel Jul 19, 2024
ffdfd6c
fix(ui): large font previews follow app theme
CyanVoxel Jul 20, 2024
3bfeb3c
fix(ui): blender previews follow app theme
CyanVoxel Jul 20, 2024
086fc1e
feat(ui): add resizable thumbnail options
CyanVoxel Jul 20, 2024
ef8cc6c
fix: mkv files with "[0][0][0][0]" codec load properly
CyanVoxel Jul 20, 2024
ad53f10
fix: missing audio files properly handled
CyanVoxel Jul 20, 2024
91ee242
feat(ui): use system accent color for thumb selections
CyanVoxel Jul 20, 2024
196c1ba
fix(ui): hide gif preview in multi-selections
CyanVoxel Jul 20, 2024
3932414
feat(ui): add dynamic file thumb icons
CyanVoxel Jul 21, 2024
c6a5202
fix(ui): hide previous thumbnail before resizing
CyanVoxel Jul 22, 2024
ad12d64
(fix): catch ffmpeg errors in file tester
CyanVoxel Jul 25, 2024
8d2e67d
Squashed commit of the following:
CyanVoxel Jul 25, 2024
6883f9e
feat(ui): add media types and icon resources
CyanVoxel Jul 25, 2024
447b5e6
feat(ui): add more default media types and icons
CyanVoxel Aug 21, 2024
c070f84
fix: remove leading dot in preview panel ext
CyanVoxel Aug 21, 2024
a244098
refactor: remove edge from `four_corner_gradient()`
CyanVoxel Aug 21, 2024
f91861d
fix: handle missing files in `resource_manager`
CyanVoxel Aug 21, 2024
e4f7055
fix(ui): thumb edges fading on refresh
CyanVoxel Aug 21, 2024
387baae
Merge branch 'Alpha-v9.4' into thumbnails
CyanVoxel Aug 21, 2024
81dfb50
feat(ui): add default icons for audio+vector thumbs
CyanVoxel Aug 21, 2024
a658fc4
feat(ui): apply edge to default icon thumbs
CyanVoxel Aug 21, 2024
ccf3d78
chore: remove unused code
CyanVoxel Aug 21, 2024
148f792
refactor(ui): move loading icon to `ResourceManager`
CyanVoxel Aug 21, 2024
9f688cd
fix(ui) color for default icons follow theme
CyanVoxel Aug 23, 2024
c377b9d
fix: remove `theme_color` redef
CyanVoxel Aug 23, 2024
12d69ba
refactor: make some consts and args clearer
CyanVoxel Aug 24, 2024
5c4a3c5
refactor: organize arguments, update docstrings
CyanVoxel Aug 25, 2024
a037a3b
chore: format docstrings with ruff
CyanVoxel Aug 25, 2024
2796db6
refactor: replace magic numbers with named values
CyanVoxel Aug 30, 2024
dd90add
refactor: remove unused code, comments, & imports
CyanVoxel Aug 30, 2024
ad0f472
refactor: rename args to not shadow builtins
CyanVoxel Aug 30, 2024
2faed27
refactor: remove unused vars from `thumb_renderer`
CyanVoxel Aug 30, 2024
69e1b20
fix: handle ValueError in `render()`
CyanVoxel Aug 30, 2024
992aa82
docs: add FFmpeg requirement to README
CyanVoxel Aug 31, 2024
31ced00
Added the option to delete files in the right click context menu
PeterBouSaada Jun 12, 2024
bbd12b5
Updated to use pathlib instead of os
PeterBouSaada Jun 13, 2024
c196171
- removed unused imports
PeterBouSaada Jun 13, 2024
c44cbbb
Swapped stacktrace to logging.exception in `file_deleter.py`
PeterBouSaada Jun 13, 2024
1e23ec8
refactor: combine `open` launch args (#364)
UnusualEgg Aug 23, 2024
959de9b
Setup and activate virtual environment via flake
seakrueger Jun 3, 2024
d0ad47f
Add xcb as fallback when wayland fails to load
seakrueger Jun 7, 2024
06a230f
Install ruff via nixpkgs
seakrueger Jun 7, 2024
8b9a0e9
Update Contributing documentation for dev on Nix
seakrueger Jun 11, 2024
26f28b5
fix(flake): resolve mypy access to libraries
xarvex Jun 16, 2024
43a0b93
Bump Qt6 version to 6.7.1
seakrueger Jun 17, 2024
9d03493
chore: bump version to v9.4.0
CyanVoxel Aug 26, 2024
662b6f5
feat: send deleted files to system trash
CyanVoxel Aug 27, 2024
ff0f09c
feat(ui): add file deletion confirmation boxes
CyanVoxel Aug 27, 2024
26777f0
feat(ui): add delete file menu option + shortcut
CyanVoxel Aug 27, 2024
0851571
ui: update file deletion message boxes
CyanVoxel Aug 28, 2024
ff2153a
fix(ui): same default confirm button on win/mac
CyanVoxel Aug 31, 2024
6f23478
fix(flake): resolve mypy access to libraries
xarvex Jun 16, 2024
b01d22a
Bump Qt6 version to 6.7.1
seakrueger Jun 17, 2024
16bcccb
feat(flake): complete revamp with devenv/direnv
xarvex Aug 25, 2024
3986725
fix(flake): add missing media dependencies
xarvex Aug 30, 2024
94851d2
fix(flake): GPU hardware acceleration
zierf Aug 30, 2024
589fefa
feat(flake): remove impurity, update nix-direnv
xarvex Aug 30, 2024
8945d11
chore(direnv): update .envrc
xarvex Aug 31, 2024
632e793
Revert "Merge branch 'main' into file-deletion"
CyanVoxel Aug 31, 2024
0e566e0
Merge branch 'Alpha-v9.4' into file-deletion
CyanVoxel Aug 31, 2024
800a405
ui: show perm deletion warning on all platforms
CyanVoxel Aug 31, 2024
d89e120
Merge branch 'Alpha-v9.4' into file-deletion
CyanVoxel Sep 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ pydub==0.25.1
mutagen==1.47.0
numpy==1.26.4
ffmpeg-python==0.2.0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have seen this in another PR, but ffmpeg needs to be installed. That should be mentioned or written how you would do that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm addressing this in #390 👍

Send2Trash==1.8.3
vtf2img==0.1.0
Binary file added tagstudio/resources/qt/videos/placeholder.mp4
Binary file not shown.
13 changes: 13 additions & 0 deletions tagstudio/src/core/media_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -169,6 +170,12 @@ class MediaCategories:
".woff",
".woff2",
}
_IMAGE_ANIMATED_SET: set[str] = {
".apng",
".gif",
".webp",
".jxl",
}
_IMAGE_RAW_SET: set[str] = {
".arw",
".cr2",
Expand Down Expand Up @@ -335,6 +342,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,
Expand Down Expand Up @@ -427,6 +439,7 @@ class MediaCategories:
DISK_IMAGE_TYPES,
DOCUMENT_TYPES,
FONT_TYPES,
IMAGE_ANIMATED_TYPES,
IMAGE_RAW_TYPES,
IMAGE_TYPES,
IMAGE_VECTOR_TYPES,
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) 2024 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
14 changes: 14 additions & 0 deletions tagstudio/src/qt/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ def __init__(self) -> None:
)
ResourceManager._initialized = True

@staticmethod
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not a classmethod This code breaks if you subclass it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar explanation here: #390 (comment)

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
Expand Down
4 changes: 4 additions & 0 deletions tagstudio/src/qt/resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,9 @@
"thumb_loading": {
"path": "qt/images/thumb_loading.png",
"mode": "pil"
},
"placeholder_mp4": {
"path": "qt/videos/placeholder.mp4",
"mode": "rb"
}
}
156 changes: 146 additions & 10 deletions tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import logging
import math
import os
import platform
import sys
import time
import typing
Expand Down Expand Up @@ -53,6 +54,7 @@
QMenu,
QMenuBar,
QComboBox,
QMessageBox,
)
from humanfriendly import format_timespan

Expand All @@ -69,9 +71,11 @@
TAG_FAVORITE,
TAG_ARCHIVED,
)
from src.core.palette import ColorType, get_ui_color
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
Expand Down Expand Up @@ -446,6 +450,15 @@ 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()

manage_file_extensions_action = QAction("Manage File Extensions", menu_bar)
manage_file_extensions_action.triggered.connect(
lambda: self.show_file_extension_modal()
Expand Down Expand Up @@ -532,13 +545,13 @@ def start(self) -> None:
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")
)
help_menu.addAction(self.repo_action)
self.set_macro_menu_viability()
self.set_menu_action_viability()

self.update_clipboard_actions()

Expand All @@ -549,6 +562,9 @@ def start(self) -> None:
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)
Expand Down Expand Up @@ -758,7 +774,7 @@ def close_library(self):
self.copied_fields.clear()
self.is_buffer_merged = False
self.update_clipboard_actions()
self.set_macro_menu_viability()
self.set_menu_action_viability()
self.preview_panel.update_widgets()
self.filter_items()
self.main_window.toggle_landing_page(True)
Expand Down Expand Up @@ -796,15 +812,15 @@ def select_all_action_callback(self):
self.selected.append((item.mode, item.item_id))
item.thumb_button.set_selected(True)

self.set_macro_menu_viability()
self.set_menu_action_viability()
self.preview_panel.update_widgets()

def clear_select_action_callback(self):
self.selected.clear()
for item in self.item_thumbs:
item.thumb_button.set_selected(False)

self.set_macro_menu_viability()
self.set_menu_action_viability()
self.preview_panel.update_widgets()

def show_tag_database(self):
Expand All @@ -827,6 +843,114 @@ def show_file_extension_modal(self):
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.
"""
entry = None
pending: list[Path] = []
deleted_count: int = 0

if len(self.selected) <= 1 and origin_path:
pending.append(Path(origin_path))
elif (len(self.selected) > 1) or (len(self.selected) <= 1 and not origin_path):
for i, item_pair in enumerate(self.selected):
if item_pair[0] == ItemType.ENTRY:
entry = self.lib.get_entry(item_pair[1])
filepath: Path = self.lib.library_dir / entry.path / entry.filename
pending.append(filepath)

if pending:
return_code = self.delete_file_confirmation(len(pending), pending[0])
logging.info(return_code)
# If there was a confirmation and not a cancellation
if return_code == 2 and return_code != 3:
for i, f in enumerate(pending):
if (origin_path == f) or (not origin_path):
self.preview_panel.stop_file_use()
if delete_file(f):
self.main_window.statusbar.showMessage(
f'Deleting file [{i}/{len(pending)}]: "{f}"...'
)
self.main_window.statusbar.repaint()

entry_id = self.lib.get_entry_id_from_filepath(f)
self.lib.remove_entry(entry_id)
self.purge_item_from_navigation(ItemType.ENTRY, entry_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'}! 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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a staticmethod?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not, but should it be?

"""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, '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 add_new_files_callback(self):
"""Runs when user initiates adding new files to the Library."""
# # if self.lib.files_not_in_library:
Expand Down Expand Up @@ -1397,17 +1521,19 @@ def select_item(self, type: ItemType, id: int, append: bool, bridge: bool):
if it.mode == type and it.item_id == id:
self.preview_panel.set_tags_updated_slot(it.update_badges)

self.set_macro_menu_viability()
self.set_menu_action_viability()
self.update_clipboard_actions()
self.preview_panel.update_widgets()

def set_macro_menu_viability(self):
def set_menu_action_viability(self):
if len([x[1] for x in self.selected if x[0] == ItemType.ENTRY]) == 0:
self.autofill_action.setDisabled(True)
self.sort_fields_action.setDisabled(True)
self.delete_file_action.setDisabled(True)
else:
self.autofill_action.setDisabled(False)
self.sort_fields_action.setDisabled(False)
self.delete_file_action.setDisabled(False)

def update_thumbs(self):
"""Updates search thumbnails."""
Expand Down Expand Up @@ -1456,12 +1582,20 @@ def update_thumbs(self):

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not log this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .disconnect() method from Qt is currently a pain point for us, as they've recently changed it from throwing a RuntimeException to a RunetimeWarning - I'm also not sure if it catches here properly due to that. It's also something that for our purposes, we don't really care if it fails - we just want to ensure that it's disconnected without caring about the previous state, initialized or otherwise. Overall we need a better solution for this, but the recent changes in Qt have made this more difficult to come up with a proper solution for.

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))
Expand Down Expand Up @@ -1506,7 +1640,9 @@ def update_thumbs(self):
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=(
Expand Down
Loading