mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-31 15:19:10 +00:00
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: yed podtrzitko <yedpodtrzitko@users.noreply.github.com> Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
This commit is contained in:
239
tagstudio/src/qt/modals/drop_import.py
Normal file
239
tagstudio/src/qt/modals/drop_import.py
Normal file
@@ -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)
|
||||
@@ -83,6 +83,7 @@ from src.qt.modals.file_extension import FileExtensionModal
|
||||
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
|
||||
@@ -267,6 +268,11 @@ class QtDriver(QObject):
|
||||
# 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)
|
||||
@@ -686,6 +692,7 @@ class QtDriver(QObject):
|
||||
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
|
||||
@@ -1108,6 +1115,7 @@ class QtDriver(QObject):
|
||||
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)
|
||||
|
||||
@@ -1459,6 +1467,7 @@ class QtDriver(QObject):
|
||||
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
|
||||
|
||||
@@ -12,8 +12,8 @@ from pathlib import Path
|
||||
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,
|
||||
@@ -104,6 +104,7 @@ class ItemThumb(FlowWidget):
|
||||
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;')
|
||||
|
||||
@@ -495,3 +496,26 @@ class ItemThumb(FlowWidget):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user