Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Drag and drop files in and out of TagStudio #153

Merged
merged 38 commits into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dccf5f2
Ability to drop local files in to TagStudio to add to library
Creepler13 May 9, 2024
44c6a96
Added renaming option to drop import
Creepler13 May 9, 2024
95fe393
Improved readability and switched to pathLib
Creepler13 May 10, 2024
c3793d5
format
Creepler13 May 10, 2024
f0cf853
Apply suggestions from code review
Creepler13 May 10, 2024
f7eaec7
Merge branch 'TagStudioDev:main' into DropFiles
Creepler13 May 10, 2024
e835a00
Revert Change
Creepler13 May 10, 2024
741b2d6
Update tagstudio/src/qt/modals/drop_import.py
Creepler13 May 10, 2024
5149855
Added support for folders
Creepler13 May 12, 2024
12c4c43
formatting
Creepler13 May 12, 2024
aeb119e
Progress bars added
Creepler13 May 12, 2024
dbbe1b0
Added Ability to Drag out of window
Creepler13 May 22, 2024
9a45ae1
f
Creepler13 May 22, 2024
1a70369
format
Creepler13 May 22, 2024
0ac25d6
Ability to drop local files in to TagStudio to add to library
Creepler13 May 9, 2024
d7a9305
Added renaming option to drop import
Creepler13 May 9, 2024
f9d1b55
Improved readability and switched to pathLib
Creepler13 May 10, 2024
374ddbc
format
Creepler13 May 10, 2024
5b7f209
Apply suggestions from code review
Creepler13 May 10, 2024
76879da
Revert Change
Creepler13 May 10, 2024
cf1402f
Update tagstudio/src/qt/modals/drop_import.py
Creepler13 May 10, 2024
db8d94e
Added support for folders
Creepler13 May 12, 2024
75acd23
formatting
Creepler13 May 12, 2024
8f8b93b
Progress bars added
Creepler13 May 12, 2024
c7f420b
Added Ability to Drag out of window
Creepler13 May 22, 2024
40873ff
f
Creepler13 May 22, 2024
234a9cb
format
Creepler13 May 22, 2024
46b706b
Rebase
Creepler13 May 22, 2024
06774b6
format
Creepler13 May 22, 2024
b453c11
formatting and refactor
Creepler13 May 22, 2024
2020985
format again
Creepler13 May 22, 2024
acb27c7
formatting for mypy
Creepler13 May 22, 2024
fb1c3a2
convert lambda to func for clarity
Creepler13 May 24, 2024
dfbf31f
mypy fixes
Creepler13 May 24, 2024
45056f1
fixed dragout only worked on selected
Creepler13 May 24, 2024
301a7b0
Refactor typo, Add license
CyanVoxel Jun 13, 2024
763d189
Reformat QMessageBox
CyanVoxel Jun 13, 2024
9549d92
Disable drops when no library is open
CyanVoxel Jun 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -90,6 +90,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 @@ -271,6 +272,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 @@ -657,6 +663,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 @@ -1059,6 +1066,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 @@ -1415,6 +1423,7 @@ def open_library(self, 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 @@ -108,6 +108,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)