Skip to content

Commit

Permalink
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 <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>
  • Loading branch information
3 people authored Jun 13, 2024
1 parent 5bc80a0 commit e5a0e5a
Show file tree
Hide file tree
Showing 3 changed files with 274 additions and 2 deletions.
239 changes: 239 additions & 0 deletions tagstudio/src/qt/modals/drop_import.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions tagstudio/src/qt/ts_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
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
Expand Down Expand Up @@ -267,6 +268,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 @@ -686,6 +692,7 @@ def close_library(self):
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
Expand Down Expand Up @@ -1108,6 +1115,7 @@ def _init_thumb_grid(self):
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)

Expand Down Expand Up @@ -1459,6 +1467,7 @@ def open_library(self, path: Path):
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
Expand Down
28 changes: 26 additions & 2 deletions tagstudio/src/qt/widgets/item_thumb.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
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,
Expand Down Expand Up @@ -104,6 +104,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
# self.setStyleSheet('background-color:red;')

Expand Down Expand Up @@ -495,3 +496,26 @@ def toggle_tag(entry: Entry):
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)

0 comments on commit e5a0e5a

Please sign in to comment.