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

* Fix ruff formatting

---------

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 Nov 3, 2024
1 parent e02eb39 commit df66b2e
Show file tree
Hide file tree
Showing 3 changed files with 290 additions and 2 deletions.
253 changes: 253 additions & 0 deletions tagstudio/src/qt/modals/drop_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio

import shutil
import typing
from pathlib import Path

from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent
from PySide6.QtWidgets import QMessageBox
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.widgets.progress import ProgressWidget

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.duplicates_choice()
else:
self.begin_transfer()

def duplicate_prompt_callback(self, button):
match button:
case self.skip_button:
self.choice = 0
case self.override_button:
self.choice = 1
case self.rename_button:
self.choice = 2
case _:
# skip on unknown state
self.choice = 0

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]

dupe_file_names = ""
for dupe in dupes_to_show:
dupe_file_names += f" {self.get_relative_path(dupe)}\n"
if len(self.duplicate_files) > display_limit:
dupe_file_names += f" and {len(self.duplicate_files) - display_limit} more\n"

self.msg_box.setText(
f"The following files: \n{dupe_file_names}"
+ "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) # type: ignore[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_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 @@ -80,6 +80,7 @@
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.main_window import Ui_MainWindow
from src.qt.modals.build_tag import BuildTagPanel
from src.qt.modals.drop_import import DropImport
from src.qt.modals.file_extension import FileExtensionModal
from src.qt.modals.fix_dupes import FixDupeFilesModal
from src.qt.modals.fix_unlinked import FixUnlinkedEntriesModal
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 df66b2e

Please sign in to comment.