Skip to content

Commit 18d57c5

Browse files
Creepler13yedpodtrzitkoCyanVoxelseakrueger
committed
feat: Drag and drop files in and out of TagStudio (#153)
* 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: Sean Krueger <skrueger2270@gmail.com> * 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 <yedpodtrzitko@users.noreply.github.com> Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com> Co-authored-by: Sean Krueger <skrueger2270@gmail.com>
1 parent 01b6906 commit 18d57c5

File tree

3 files changed

+287
-2
lines changed

3 files changed

+287
-2
lines changed
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# Licensed under the GPL-3.0 License.
2+
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
3+
4+
from pathlib import Path
5+
import shutil
6+
import typing
7+
8+
from PySide6.QtCore import QThreadPool
9+
from PySide6.QtGui import QDropEvent, QDragEnterEvent, QDragMoveEvent
10+
from PySide6.QtWidgets import QMessageBox
11+
12+
from src.qt.widgets.progress import ProgressWidget
13+
from src.qt.helpers.custom_runnable import CustomRunnable
14+
from src.qt.helpers.function_iterator import FunctionIterator
15+
16+
if typing.TYPE_CHECKING:
17+
from src.qt.ts_qt import QtDriver
18+
19+
import logging
20+
21+
22+
class DropImport:
23+
def __init__(self, driver: "QtDriver"):
24+
self.driver = driver
25+
26+
def dropEvent(self, event: QDropEvent): # noqa: N802
27+
if (
28+
event.source() is self.driver
29+
): # change that if you want to drop something originating from tagstudio, for moving or so
30+
return
31+
32+
if not event.mimeData().hasUrls():
33+
return
34+
35+
self.urls = event.mimeData().urls()
36+
self.import_files()
37+
38+
def dragEnterEvent(self, event: QDragEnterEvent): # noqa: N802
39+
if event.mimeData().hasUrls():
40+
event.accept()
41+
else:
42+
event.ignore()
43+
44+
def dragMoveEvent(self, event: QDragMoveEvent): # noqa: N802
45+
if event.mimeData().hasUrls():
46+
event.accept()
47+
else:
48+
logging.info(self.driver.selected)
49+
event.ignore()
50+
51+
def import_files(self):
52+
self.files: list[Path] = []
53+
self.dirs_in_root: list[Path] = []
54+
self.duplicate_files: list[Path] = []
55+
56+
def displayed_text(x):
57+
text = f"Searching New Files...\n{x[0] + 1} File{'s' if x[0] + 1 != 1 else ''} Found."
58+
if x[1] == 0:
59+
return text
60+
return text + f" {x[1]} Already exist in the library folders"
61+
62+
create_progress_bar(
63+
self.collect_files_to_import,
64+
"Searching Files",
65+
"Searching New Files...\nPreparing...",
66+
displayed_text,
67+
self.ask_user,
68+
)
69+
70+
def collect_files_to_import(self):
71+
for url in self.urls:
72+
if not url.isLocalFile():
73+
continue
74+
75+
file = Path(url.toLocalFile())
76+
77+
if file.is_dir():
78+
for f in self.get_files_in_folder(file):
79+
if f.is_dir():
80+
continue
81+
self.files.append(f)
82+
if (self.driver.lib.library_dir / self.get_relative_path(file)).exists():
83+
self.duplicate_files.append(f)
84+
yield [len(self.files), len(self.duplicate_files)]
85+
86+
self.dirs_in_root.append(file.parent)
87+
else:
88+
self.files.append(file)
89+
90+
if file.parent not in self.dirs_in_root:
91+
self.dirs_in_root.append(
92+
file.parent
93+
) # to create relative path of files not in folder
94+
95+
if (Path(self.driver.lib.library_dir) / file.name).exists():
96+
self.duplicate_files.append(file)
97+
98+
yield [len(self.files), len(self.duplicate_files)]
99+
100+
def copy_files(self):
101+
file_count = 0
102+
duplicated_files_progress = 0
103+
for file in self.files:
104+
if file.is_dir():
105+
continue
106+
107+
dest_file = self.get_relative_path(file)
108+
109+
if file in self.duplicate_files:
110+
duplicated_files_progress += 1
111+
if self.choice == 1: # override
112+
pass
113+
elif self.choice == 2: # rename
114+
new_name = self.get_renamed_duplicate_filename_in_lib(dest_file)
115+
dest_file = dest_file.with_name(new_name)
116+
else: # skip
117+
continue
118+
119+
(self.driver.lib.library_dir / dest_file).parent.mkdir(parents=True, exist_ok=True)
120+
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
121+
122+
file_count += 1
123+
yield [file_count, duplicated_files_progress]
124+
125+
def ask_user(self):
126+
self.choice = -1
127+
128+
if len(self.duplicate_files) > 0:
129+
self.choice = self.duplicates_choice()
130+
else:
131+
self.begin_transfer()
132+
133+
def duplicate_prompt_callback(self, button):
134+
if button == self.skip_button:
135+
self.choice = 0
136+
elif button == self.override_button:
137+
self.choice = 1
138+
elif button == self.rename_button:
139+
self.choice = 2
140+
else:
141+
return
142+
143+
self.begin_transfer()
144+
145+
def begin_transfer(self):
146+
def displayed_text(x):
147+
dupes_choice_text = (
148+
"Skipped" if self.choice == 0 else ("Overridden" if self.choice == 1 else "Renamed")
149+
)
150+
151+
text = (
152+
f"Importing New Files...\n{x[0] + 1} File{'s' if x[0] + 1 != 1 else ''} Imported."
153+
)
154+
if x[1] == 0:
155+
return text
156+
return text + f" {x[1]} {dupes_choice_text}"
157+
158+
create_progress_bar(
159+
self.copy_files,
160+
"Import Files",
161+
"Importing New Files...\nPreparing...",
162+
displayed_text,
163+
self.driver.add_new_files_callback,
164+
len(self.files),
165+
)
166+
167+
def duplicates_choice(self):
168+
display_limit: int = 5
169+
self.msg_box = QMessageBox()
170+
self.msg_box.setWindowTitle(f"File Conflict{'s' if len(self.duplicate_files) > 1 else ''}")
171+
172+
dupes_to_show = self.duplicate_files
173+
if len(self.duplicate_files) > display_limit:
174+
dupes_to_show = dupes_to_show[0:display_limit]
175+
176+
self.msg_box.setText(
177+
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."
178+
)
179+
self.skip_button = self.msg_box.addButton("Skip", QMessageBox.ButtonRole.YesRole)
180+
self.override_button = self.msg_box.addButton(
181+
"Override", QMessageBox.ButtonRole.DestructiveRole
182+
)
183+
self.rename_button = self.msg_box.addButton(
184+
"Rename", QMessageBox.ButtonRole.DestructiveRole
185+
)
186+
self.cancel_button = self.msg_box.setStandardButtons(QMessageBox.Cancel)
187+
188+
self.msg_box.buttonClicked.connect(lambda button: self.duplicate_prompt_callback(button))
189+
self.msg_box.open()
190+
191+
def get_files_exists_in_library(self, path: Path) -> list[Path]:
192+
exists: list[Path] = []
193+
if not path.is_dir():
194+
return exists
195+
196+
files = self.get_files_in_folder(path)
197+
for file in files:
198+
if file.is_dir():
199+
exists += self.get_files_exists_in_library(file)
200+
elif (self.driver.lib.library_dir / self.get_relative_path(file)).exists():
201+
exists.append(file)
202+
return exists
203+
204+
def get_relative_paths(self, paths: list[Path]) -> list[Path]:
205+
relative_paths = []
206+
for file in paths:
207+
relative_paths.append(self.get_relative_path(file))
208+
return relative_paths
209+
210+
def get_relative_path(self, path: Path) -> Path:
211+
for dir in self.dirs_in_root:
212+
if path.is_relative_to(dir):
213+
return path.relative_to(dir)
214+
return Path(path.name)
215+
216+
def get_files_in_folder(self, path: Path) -> list[Path]:
217+
files = []
218+
for file in path.glob("**/*"):
219+
files.append(file)
220+
return files
221+
222+
def get_renamed_duplicate_filename_in_lib(self, filepath: Path) -> str:
223+
index = 2
224+
o_filename = filepath.name
225+
dot_idx = o_filename.index(".")
226+
while (self.driver.lib.library_dir / filepath).exists():
227+
filepath = filepath.with_name(
228+
o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:]
229+
)
230+
index += 1
231+
return filepath.name
232+
233+
234+
def create_progress_bar(
235+
function, title: str, text: str, update_label_callback, done_callback, max=0
236+
):
237+
iterator = FunctionIterator(function)
238+
pw = ProgressWidget(
239+
window_title=title,
240+
label_text=text,
241+
cancel_button_text=None,
242+
minimum=0,
243+
maximum=max,
244+
)
245+
pw.show()
246+
iterator.value.connect(lambda x: pw.update_progress(x[0] + 1))
247+
iterator.value.connect(lambda x: pw.update_label(update_label_callback(x)))
248+
r = CustomRunnable(lambda: iterator.run())
249+
r.done.connect(lambda: (pw.hide(), done_callback())) # type: ignore
250+
QThreadPool.globalInstance().start(r)

tagstudio/src/qt/ts_qt.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
from src.qt.widgets.preview_panel import PreviewPanel
9191
from src.qt.widgets.progress import ProgressWidget
9292
from src.qt.widgets.thumb_renderer import ThumbRenderer
93+
from src.qt.modals.drop_import import DropImport
9394

9495
# SIGQUIT is not defined on Windows
9596
if sys.platform == "win32":
@@ -235,6 +236,11 @@ def start(self) -> None:
235236
# f'QScrollBar::{{background:red;}}'
236237
# )
237238

239+
self.drop_import = DropImport(self)
240+
self.main_window.dragEnterEvent = self.drop_import.dragEnterEvent # type: ignore
241+
self.main_window.dropEvent = self.drop_import.dropEvent # type: ignore
242+
self.main_window.dragMoveEvent = self.drop_import.dragMoveEvent # type: ignore
243+
238244
# # self.main_window.windowFlags() &
239245
# # self.main_window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
240246
# self.main_window.setWindowFlag(Qt.WindowType.NoDropShadowWindowHint, True)
@@ -890,6 +896,7 @@ def _init_thumb_grid(self):
890896
item_thumb = ItemThumb(
891897
None, self.lib, self, (self.thumb_size, self.thumb_size), grid_idx
892898
)
899+
893900
layout.addWidget(item_thumb)
894901
self.item_thumbs.append(item_thumb)
895902

@@ -1123,6 +1130,7 @@ def open_library(self, path: Path | str):
11231130
self.update_libs_list(path)
11241131
title_text = f"{self.base_title} - Library '{self.lib.library_dir}'"
11251132
self.main_window.setWindowTitle(title_text)
1133+
self.main_window.setAcceptDrops(True)
11261134

11271135
self.selected.clear()
11281136
self.preview_panel.update_widgets()

tagstudio/src/qt/widgets/item_thumb.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111
import structlog
1212
from PIL import Image, ImageQt
13-
from PySide6.QtCore import QEvent, QSize, Qt
14-
from PySide6.QtGui import QAction, QEnterEvent, QPixmap
13+
from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl
14+
from PySide6.QtGui import QAction, QDrag, QEnterEvent, QPixmap
1515
from PySide6.QtWidgets import (
1616
QBoxLayout,
1717
QCheckBox,
@@ -128,6 +128,7 @@ def __init__(
128128
self.thumb_size: tuple[int, int] = thumb_size
129129
self.setMinimumSize(*thumb_size)
130130
self.setMaximumSize(*thumb_size)
131+
self.setMouseTracking(True)
131132
check_size = 24
132133

133134
# +----------+
@@ -483,3 +484,29 @@ def toggle_item_tag(
483484

484485
if self.driver.preview_panel.is_open:
485486
self.driver.preview_panel.update_widgets()
487+
488+
def mouseMoveEvent(self, event): # noqa: N802
489+
if event.buttons() is not Qt.MouseButton.LeftButton:
490+
return
491+
492+
drag = QDrag(self.driver)
493+
paths = []
494+
mimedata = QMimeData()
495+
496+
selected_idxs = self.driver.selected
497+
if self.grid_idx not in selected_idxs:
498+
selected_idxs = [self.grid_idx]
499+
500+
for grid_idx in selected_idxs:
501+
id = self.driver.item_thumbs[grid_idx].item_id
502+
entry = self.lib.get_entry(id)
503+
if not entry:
504+
continue
505+
506+
url = QUrl.fromLocalFile(Path(self.lib.library_dir) / entry.path)
507+
paths.append(url)
508+
509+
mimedata.setUrls(paths)
510+
drag.setMimeData(mimedata)
511+
drag.exec(Qt.DropAction.CopyAction)
512+
logger.info("dragged files to external program", thumbnail_indexs=selected_idxs)

0 commit comments

Comments
 (0)