Drag and drop files in and out of TagStudio (#153)

* Ability to drop local files in to TagStudio to add to library

* Added renaming option to drop import

* Improved readability and switched to pathLib

* format

* Apply suggestions from code review

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Revert Change

* Update tagstudio/src/qt/modals/drop_import.py

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Added support for folders

* formatting

* Progress bars added

* Added Ability to Drag out of window

* f

* format

* Ability to drop local files in to TagStudio to add to library

* Added renaming option to drop import

* Improved readability and switched to pathLib

* format

* Apply suggestions from code review

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Revert Change

* Update tagstudio/src/qt/modals/drop_import.py

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>

* Added support for folders

* formatting

* Progress bars added

* Added Ability to Drag out of window

* f

* format

* format

* formatting and refactor

* format again

* formatting for mypy

* convert lambda to func for clarity

* mypy fixes

* fixed dragout only worked on selected

* Refactor typo, Add license

* Reformat QMessageBox

* Disable drops when no library is open

---------

Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com>
Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
This commit is contained in:
Creepler13
2024-06-13 19:59:10 +02:00
committed by GitHub
parent 5bc80a043f
commit e5a0e5aa9b
3 changed files with 274 additions and 2 deletions

View File

@@ -0,0 +1,239 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from pathlib import Path
import shutil
import typing
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QDropEvent, QDragEnterEvent, QDragMoveEvent
from PySide6.QtWidgets import QMessageBox
from src.qt.widgets.progress import ProgressWidget
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
import logging
class DropImport:
def __init__(self, driver: "QtDriver"):
self.driver = driver
def dropEvent(self, event: QDropEvent):
if (
event.source() is self.driver
): # change that if you want to drop something originating from tagstudio, for moving or so
return
if not event.mimeData().hasUrls():
return
self.urls = event.mimeData().urls()
self.import_files()
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.accept()
else:
event.ignore()
def dragMoveEvent(self, event: QDragMoveEvent):
if event.mimeData().hasUrls():
event.accept()
else:
logging.info(self.driver.selected)
event.ignore()
def import_files(self):
self.files: list[Path] = []
self.dirs_in_root: list[Path] = []
self.duplicate_files: list[Path] = []
def displayed_text(x):
text = f"Searching New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Found."
if x[1] == 0:
return text
return text + f" {x[1]} Already exist in the library folders"
create_progress_bar(
self.collect_files_to_import,
"Searching Files",
"Searching New Files...\nPreparing...",
displayed_text,
self.ask_user,
)
def collect_files_to_import(self):
for url in self.urls:
if not url.isLocalFile():
continue
file = Path(url.toLocalFile())
if file.is_dir():
for f in self.get_files_in_folder(file):
if f.is_dir():
continue
self.files.append(f)
if (
self.driver.lib.library_dir / self.get_relative_path(file)
).exists():
self.duplicate_files.append(f)
yield [len(self.files), len(self.duplicate_files)]
self.dirs_in_root.append(file.parent)
else:
self.files.append(file)
if file.parent not in self.dirs_in_root:
self.dirs_in_root.append(
file.parent
) # to create relative path of files not in folder
if (Path(self.driver.lib.library_dir) / file.name).exists():
self.duplicate_files.append(file)
yield [len(self.files), len(self.duplicate_files)]
def copy_files(self):
fileCount = 0
duplicated_files_progress = 0
for file in self.files:
if file.is_dir():
continue
dest_file = self.get_relative_path(file)
if file in self.duplicate_files:
duplicated_files_progress += 1
if self.choice == 0: # skip duplicates
continue
if self.choice == 2: # rename
new_name = self.get_renamed_duplicate_filename_in_lib(dest_file)
dest_file = dest_file.with_name(new_name)
self.driver.lib.files_not_in_library.append(dest_file)
else: # override is simply copying but not adding a new entry
self.driver.lib.files_not_in_library.append(dest_file)
(self.driver.lib.library_dir / dest_file).parent.mkdir(
parents=True, exist_ok=True
)
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
fileCount += 1
yield [fileCount, duplicated_files_progress]
def ask_user(self):
self.choice = -1
if len(self.duplicate_files) > 0:
self.choice = self.duplicates_choice()
if self.choice == 3: # cancel
return
def displayed_text(x):
dupes_choice_text = (
"Skipped"
if self.choice == 0
else ("Overridden" if self.choice == 1 else "Renamed")
)
text = f"Importing New Files...\n{x[0]+1} File{'s' if x[0]+1 != 1 else ''} Imported."
if x[1] == 0:
return text
return text + f" {x[1]} {dupes_choice_text}"
create_progress_bar(
self.copy_files,
"Import Files",
"Importing New Files...\nPreparing...",
displayed_text,
self.driver.add_new_files_runnable,
len(self.files),
)
def duplicates_choice(self) -> int:
display_limit: int = 5
msgBox = QMessageBox()
msgBox.setWindowTitle(
f"File Conflict{'s' if len(self.duplicate_files) > 1 else ''}"
)
dupes_to_show = self.duplicate_files
if len(self.duplicate_files) > display_limit:
dupes_to_show = dupes_to_show[0:display_limit]
msgBox.setText(
f"The following files:\n {'\n '.join(map(lambda path: str(path),self.get_relative_paths(dupes_to_show)))} {(f'\nand {len(self.duplicate_files)-display_limit} more ') if len(self.duplicate_files)>display_limit else '\n'}have filenames that already exist in the library folder."
)
msgBox.addButton("Skip", QMessageBox.ButtonRole.YesRole)
msgBox.addButton("Override", QMessageBox.ButtonRole.DestructiveRole)
msgBox.addButton("Rename", QMessageBox.ButtonRole.DestructiveRole)
msgBox.addButton("Cancel", QMessageBox.ButtonRole.NoRole)
return msgBox.exec()
def get_files_exists_in_library(self, path: Path) -> list[Path]:
exists: list[Path] = []
if not path.is_dir():
return exists
files = self.get_files_in_folder(path)
for file in files:
if file.is_dir():
exists += self.get_files_exists_in_library(file)
elif (self.driver.lib.library_dir / self.get_relative_path(file)).exists():
exists.append(file)
return exists
def get_relative_paths(self, paths: list[Path]) -> list[Path]:
relative_paths = []
for file in paths:
relative_paths.append(self.get_relative_path(file))
return relative_paths
def get_relative_path(self, path: Path) -> Path:
for dir in self.dirs_in_root:
if path.is_relative_to(dir):
return path.relative_to(dir)
return Path(path.name)
def get_files_in_folder(self, path: Path) -> list[Path]:
files = []
for file in path.glob("**/*"):
files.append(file)
return files
def get_renamed_duplicate_filename_in_lib(self, filePath: Path) -> str:
index = 2
o_filename = filePath.name
dot_idx = o_filename.index(".")
while (self.driver.lib.library_dir / filePath).exists():
filePath = filePath.with_name(
o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:]
)
index += 1
return filePath.name
def create_progress_bar(
function, title: str, text: str, update_label_callback, done_callback, max=0
):
iterator = FunctionIterator(function)
pw = ProgressWidget(
window_title=title,
label_text=text,
cancel_button_text=None,
minimum=0,
maximum=max,
)
pw.show()
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
iterator.value.connect(lambda x: pw.update_label(update_label_callback(x)))
r = CustomRunnable(lambda: iterator.run())
r.done.connect(lambda: (pw.hide(), done_callback())) # type: ignore
QThreadPool.globalInstance().start(r)

View File

@@ -83,6 +83,7 @@ from src.qt.modals.file_extension import FileExtensionModal
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
from src.qt.modals.fix_dupes import FixDupeFilesModal
from src.qt.modals.folders_to_tags import FoldersToTagsModal
from src.qt.modals.drop_import import DropImport
# this import has side-effect of import PySide resources
import src.qt.resources_rc # pylint: disable=unused-import
@@ -267,6 +268,11 @@ class QtDriver(QObject):
# f'QScrollBar::{{background:red;}}'
# )
self.drop_import = DropImport(self)
self.main_window.dragEnterEvent = self.drop_import.dragEnterEvent # type: ignore
self.main_window.dropEvent = self.drop_import.dropEvent # type: ignore
self.main_window.dragMoveEvent = self.drop_import.dragMoveEvent # type: ignore
# # self.main_window.windowFlags() &
# # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
# self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
@@ -686,6 +692,7 @@ class QtDriver(QObject):
self.lib.clear_internal_vars()
title_text = f"{self.base_title}"
self.main_window.setWindowTitle(title_text)
self.main_window.setAcceptDrops(False)
self.nav_frames = []
self.cur_frame_idx = -1
@@ -1108,6 +1115,7 @@ class QtDriver(QObject):
item_thumb = ItemThumb(
None, self.lib, self.preview_panel, (self.thumb_size, self.thumb_size)
)
layout.addWidget(item_thumb)
self.item_thumbs.append(item_thumb)
@@ -1459,6 +1467,7 @@ class QtDriver(QObject):
self.update_libs_list(path)
title_text = f"{self.base_title} - Library '{self.lib.library_dir}'"
self.main_window.setWindowTitle(title_text)
self.main_window.setAcceptDrops(True)
self.nav_frames = []
self.cur_frame_idx = -1

View File

@@ -12,8 +12,8 @@ from pathlib import Path
from typing import Optional
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QSize, QEvent
from PySide6.QtGui import QPixmap, QEnterEvent, QAction
from PySide6.QtCore import Qt, QSize, QEvent, QMimeData, QUrl
from PySide6.QtGui import QPixmap, QEnterEvent, QAction, QDrag
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
@@ -104,6 +104,7 @@ class ItemThumb(FlowWidget):
self.thumb_size: tuple[int, int] = thumb_size
self.setMinimumSize(*thumb_size)
self.setMaximumSize(*thumb_size)
self.setMouseTracking(True)
check_size = 24
# self.setStyleSheet('background-color:red;')
@@ -495,3 +496,26 @@ class ItemThumb(FlowWidget):
if self.panel.isOpen:
self.panel.update_widgets()
self.panel.driver.update_badges()
def mouseMoveEvent(self, event):
if event.buttons() is not Qt.MouseButton.LeftButton:
return
drag = QDrag(self.panel.driver)
paths = []
mimedata = QMimeData()
selected_ids = list(map(lambda x: x[1], self.panel.driver.selected))
if self.item_id not in selected_ids:
selected_ids = [self.item_id]
for id in selected_ids:
entry = self.lib.get_entry(id)
url = QUrl.fromLocalFile(
Path(self.lib.library_dir) / entry.path / entry.filename
)
paths.append(url)
mimedata.setUrls(paths)
drag.setMimeData(mimedata)
drag.exec(Qt.DropAction.CopyAction)