Skip to content

Commit

Permalink
feat: Drag and drop files in and out of TagStudio (#153)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Revert Change

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

Co-authored-by: yed podtrzitko <[email protected]>

* 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 <[email protected]>

* Revert Change

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

Co-authored-by: yed podtrzitko <[email protected]>

* 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: Sean Krueger <[email protected]>

* Rebased onto SQL migration

* Updated logic to based on selected grid_idx instead of selected ids

* Add newly dragged-in files to SQL database

* Fix buttons being inconsistant across platforms

---------

Co-authored-by: yed podtrzitko <[email protected]>
Co-authored-by: Travis Abendshien <[email protected]>
Co-authored-by: Sean Krueger <[email protected]>
  • Loading branch information
4 people committed Oct 7, 2024
1 parent 7dd0f3d commit 0ddd9c7
Show file tree
Hide file tree
Showing 3 changed files with 287 additions and 2 deletions.
250 changes: 250 additions & 0 deletions tagstudio/src/qt/modals/drop_import.py
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 186 in tagstudio/src/qt/modals/drop_import.py

View workflow job for this annotation

GitHub Actions / Run MyPy

[mypy] reported by reviewdog 🐶 "type[QMessageBox]" has no attribute "Cancel" [attr-defined] Raw Output: /home/runner/work/TagStudio/TagStudio/tagstudio/src/qt/modals/drop_import.py:186:62: error: "type[QMessageBox]" has no attribute "Cancel" [attr-defined]

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)
8 changes: 8 additions & 0 deletions tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
31 changes: 29 additions & 2 deletions tagstudio/src/qt/widgets/item_thumb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

# +----------+
Expand Down Expand Up @@ -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)

0 comments on commit 0ddd9c7

Please sign in to comment.