diff --git a/tagstudio/src/qt/modals/drop_import.py b/tagstudio/src/qt/modals/drop_import.py new file mode 100644 index 00000000..8982dd92 --- /dev/null +++ b/tagstudio/src/qt/modals/drop_import.py @@ -0,0 +1,250 @@ +# 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): # noqa: N802 + 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): # noqa: N802 + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event: QDragMoveEvent): # noqa: N802 + 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): + file_count = 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 == 1: # override + pass + elif self.choice == 2: # rename + new_name = self.get_renamed_duplicate_filename_in_lib(dest_file) + dest_file = dest_file.with_name(new_name) + else: # skip + continue + + (self.driver.lib.library_dir / dest_file).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(file, self.driver.lib.library_dir / dest_file) + + file_count += 1 + yield [file_count, duplicated_files_progress] + + def ask_user(self): + self.choice = -1 + + if len(self.duplicate_files) > 0: + self.choice = self.duplicates_choice() + else: + self.begin_transfer() + + def duplicate_prompt_callback(self, button): + if button == self.skip_button: + self.choice = 0 + elif button == self.override_button: + self.choice = 1 + elif button == self.rename_button: + self.choice = 2 + else: + return + + self.begin_transfer() + + def begin_transfer(self): + 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_callback, + len(self.files), + ) + + def duplicates_choice(self): + display_limit: int = 5 + self.msg_box = QMessageBox() + self.msg_box.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] + + self.msg_box.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." + ) + self.skip_button = self.msg_box.addButton("Skip", QMessageBox.ButtonRole.YesRole) + self.override_button = self.msg_box.addButton( + "Override", QMessageBox.ButtonRole.DestructiveRole + ) + self.rename_button = self.msg_box.addButton( + "Rename", QMessageBox.ButtonRole.DestructiveRole + ) + self.cancel_button = self.msg_box.setStandardButtons(QMessageBox.Cancel) + + self.msg_box.buttonClicked.connect(lambda button: self.duplicate_prompt_callback(button)) + self.msg_box.open() + + 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) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index e80aa84e..76ef12f4 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -91,6 +91,7 @@ from src.qt.widgets.preview_panel import PreviewPanel from src.qt.widgets.progress import ProgressWidget from src.qt.widgets.thumb_renderer import ThumbRenderer +from src.qt.modals.drop_import import DropImport # SIGQUIT is not defined on Windows if sys.platform == "win32": @@ -235,6 +236,11 @@ def start(self) -> None: # 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) @@ -892,6 +898,7 @@ def _init_thumb_grid(self): item_thumb = ItemThumb( None, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx ) + layout.addWidget(item_thumb) self.item_thumbs.append(item_thumb) @@ -1130,6 +1137,7 @@ def open_library(self, path: Path) -> LibraryStatus: 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.selected.clear() self.preview_panel.update_widgets() diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index da82a19d..546e46a6 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -10,8 +10,8 @@ import structlog from PIL import Image, ImageQt -from PySide6.QtCore import QEvent, QSize, Qt -from PySide6.QtGui import QAction, QEnterEvent, QPixmap +from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl +from PySide6.QtGui import QAction, QDrag, QEnterEvent, QPixmap from PySide6.QtWidgets import ( QBoxLayout, QCheckBox, @@ -128,6 +128,7 @@ def __init__( self.thumb_size: tuple[int, int] = thumb_size self.setMinimumSize(*thumb_size) self.setMaximumSize(*thumb_size) + self.setMouseTracking(True) check_size = 24 # +----------+ @@ -483,3 +484,29 @@ def toggle_item_tag( if self.driver.preview_panel.is_open: self.driver.preview_panel.update_widgets() + + def mouseMoveEvent(self, event): # noqa: N802 + if event.buttons() is not Qt.MouseButton.LeftButton: + return + + drag = QDrag(self.driver) + paths = [] + mimedata = QMimeData() + + selected_idxs = self.driver.selected + if self.grid_idx not in selected_idxs: + selected_idxs = [self.grid_idx] + + for grid_idx in selected_idxs: + id = self.driver.item_thumbs[grid_idx].item_id + entry = self.lib.get_entry(id) + if not entry: + continue + + url = QUrl.fromLocalFile(Path(self.lib.library_dir) / entry.path) + paths.append(url) + + mimedata.setUrls(paths) + drag.setMimeData(mimedata) + drag.exec(Qt.DropAction.CopyAction) + logger.info("dragged files to external program", thumbnail_indexs=selected_idxs)