Skip to content

Commit

Permalink
feat: reimplement drag drop files (Port TagStudioDev#153) (TagStudioD…
Browse files Browse the repository at this point in the history
…ev#528)

* feat: Drag and drop files in and out of TagStudio (TagStudioDev#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 <[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

* Rename "override" button to "overwrite"

---------

Co-authored-by: yed podtrzitko <[email protected]>
Co-authored-by: Travis Abendshien <[email protected]>
Co-authored-by: Sean Krueger <[email protected]>

* refactor: Update dialog and simplify drop import logic

* Handle Qt events for main window in ts_qt.py

* Replace magic values with enums

* Match import duplicate file dialog to delete missing entry dialog

* Remove excessive progess widgets

* Add docstrings and logging

* refactor: add function for common ProgressWidget use

Extracts the create_progress_bar function from drop_import to the
ProgressWidget class. Instead of creating a ProgressWidget,
FunctionIterator, and CustomRunnable every time a thread-safe progress
widget is needed, the from_iterable function in ProgressWidget now
handles all of that.

---------

Co-authored-by: Creepler13 <[email protected]>
Co-authored-by: yed podtrzitko <[email protected]>
Co-authored-by: Travis Abendshien <[email protected]>
  • Loading branch information
4 people authored and DandyDev01 committed Dec 13, 2024
1 parent ec7faa9 commit 6e63b0e
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 137 deletions.
31 changes: 8 additions & 23 deletions tagstudio/src/qt/modals/delete_unlinked.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import typing

from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
Expand All @@ -15,8 +15,6 @@
QWidget,
)
from src.core.utils.missing_files import MissingRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.widgets.progress import ProgressWidget

# Only import for type checking/autocompletion, will not be imported at runtime.
Expand Down Expand Up @@ -77,33 +75,20 @@ def refresh_list(self):

self.model.clear()
for i in self.tracker.missing_files:
self.model.appendRow(QStandardItem(str(i.path)))
item = QStandardItem(str(i.path))
item.setEditable(False)
self.model.appendRow(item)

def delete_entries(self):
def displayed_text(x):
return f"Deleting {x}/{self.tracker.missing_files_count} Unlinked Entries"

pw = ProgressWidget(
window_title="Deleting Entries",
label_text="",
cancel_button_text=None,
minimum=0,
maximum=self.tracker.missing_files_count,
)
pw.show()

iterator = FunctionIterator(self.tracker.execute_deletion)
files_count = self.tracker.missing_files_count
iterator.value.connect(
lambda idx: (
pw.update_progress(idx),
pw.update_label(f"Deleting {idx}/{files_count} Unlinked Entries"),
)
)

r = CustomRunnable(iterator.run)
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.done.emit(),
)
)
pw.from_iterable_function(self.tracker.execute_deletion, displayed_text, self.done.emit)
229 changes: 229 additions & 0 deletions tagstudio/src/qt/modals/drop_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio

import enum
import shutil
from pathlib import Path
from typing import TYPE_CHECKING

import structlog
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListView,
QPushButton,
QVBoxLayout,
QWidget,
)
from src.qt.widgets.progress import ProgressWidget

if TYPE_CHECKING:
from src.qt.ts_qt import QtDriver

logger = structlog.get_logger(__name__)


class DuplicateChoice(enum.StrEnum):
SKIP = "Skipped"
OVERWRITE = "Overwritten"
RENAME = "Renamed"
CANCEL = "Cancelled"


class DropImportModal(QWidget):
DUPE_NAME_LIMT: int = 5

def __init__(self, driver: "QtDriver"):
super().__init__()

self.driver: QtDriver = driver

# Widget ======================
self.setWindowTitle("Conflicting File(s)")
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setMinimumSize(500, 400)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 6, 6, 6)

self.desc_widget = QLabel()
self.desc_widget.setObjectName("descriptionLabel")
self.desc_widget.setWordWrap(True)
self.desc_widget.setText("The following files have filenames already exist in the library")
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)

# Duplicate File List ========
self.list_view = QListView()
self.model = QStandardItemModel()
self.list_view.setModel(self.model)

# Buttons ====================
self.button_container = QWidget()
self.button_layout = QHBoxLayout(self.button_container)
self.button_layout.setContentsMargins(6, 6, 6, 6)
self.button_layout.addStretch(1)

self.skip_button = QPushButton()
self.skip_button.setText("&Skip")
self.skip_button.setDefault(True)
self.skip_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.SKIP))
self.button_layout.addWidget(self.skip_button)

self.overwrite_button = QPushButton()
self.overwrite_button.setText("&Overwrite")
self.overwrite_button.clicked.connect(
lambda: self.begin_transfer(DuplicateChoice.OVERWRITE)
)
self.button_layout.addWidget(self.overwrite_button)

self.rename_button = QPushButton()
self.rename_button.setText("&Rename")
self.rename_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.RENAME))
self.button_layout.addWidget(self.rename_button)

self.cancel_button = QPushButton()
self.cancel_button.setText("&Cancel")
self.cancel_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.CANCEL))
self.button_layout.addWidget(self.cancel_button)

# Layout =====================
self.root_layout.addWidget(self.desc_widget)
self.root_layout.addWidget(self.list_view)
self.root_layout.addWidget(self.button_container)

def import_urls(self, urls: list[QUrl]):
"""Add a colleciton of urls to the library."""
self.files: list[Path] = []
self.dirs_in_root: list[Path] = []
self.duplicate_files: list[Path] = []

self.collect_files_to_import(urls)

if len(self.duplicate_files) > 0:
self.ask_duplicates_choice()
else:
self.begin_transfer()

def collect_files_to_import(self, urls: list[QUrl]):
"""Collect one or more files from drop event urls."""
for url in urls:
if not url.isLocalFile():
continue

file = Path(url.toLocalFile())

if file.is_dir():
for f in file.glob("**/*"):
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)

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)

def ask_duplicates_choice(self):
"""Display the message widgeth with a list of the duplicated files."""
self.desc_widget.setText(
f"The following {len(self.duplicate_files)} file(s) have filenames already exist in the library." # noqa: E501
)

self.model.clear()
for dupe in self.duplicate_files:
item = QStandardItem(str(self._get_relative_path(dupe)))
item.setEditable(False)
self.model.appendRow(item)

self.driver.main_window.raise_()
self.show()

def begin_transfer(self, choice: DuplicateChoice | None = None):
"""Display a progress bar and begin copying files into library."""
self.hide()
self.choice: DuplicateChoice | None = choice
logger.info("duplicated choice selected", choice=self.choice)
if self.choice == DuplicateChoice.CANCEL:
return

def displayed_text(x):
text = (
f"Importing New Files...\n{x[0] + 1} File{'s' if x[0] + 1 != 1 else ''} Imported."
)
if self.choice:
text += f" {x[1]} {self.choice.value}"

return text

pw = ProgressWidget(
window_title="Import Files",
label_text="Importing New Files...",
cancel_button_text=None,
minimum=0,
maximum=len(self.files),
)

pw.from_iterable_function(
self.copy_files,
displayed_text,
self.driver.add_new_files_callback,
self.deleteLater,
)

def copy_files(self):
"""Copy files from original location to the library directory."""
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 == DuplicateChoice.SKIP:
file_count += 1
continue
elif self.choice == DuplicateChoice.RENAME:
new_name = self._get_renamed_duplicate_filename(dest_file)
dest_file = dest_file.with_name(new_name)

(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 _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_renamed_duplicate_filename(self, filepath: Path) -> str:
index = 2
o_filename = filepath.name

try:
dot_idx = o_filename.index(".")
except ValueError:
dot_idx = len(o_filename)

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
32 changes: 13 additions & 19 deletions tagstudio/src/qt/modals/fix_unlinked.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@

import typing

from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from src.core.library import Library
from src.core.utils.missing_files import MissingRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
Expand Down Expand Up @@ -85,7 +83,7 @@ def __init__(self, library: "Library", driver: "QtDriver"):
self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker)
self.delete_modal.done.connect(
lambda: (
self.set_missing_count(self.tracker.missing_files_count),
self.set_missing_count(),
# refresh the grid
self.driver.filter_items(),
)
Expand Down Expand Up @@ -125,23 +123,19 @@ def refresh_missing_files(self):
maximum=self.lib.entries_count,
)

pw.show()

iterator = FunctionIterator(self.tracker.refresh_missing_files)
iterator.value.connect(lambda v: pw.update_progress(v + 1))
r = CustomRunnable(iterator.run)
QThreadPool.globalInstance().start(r)
r.done.connect(
lambda: (
pw.hide(),
pw.deleteLater(),
self.set_missing_count(self.tracker.missing_files_count),
self.delete_modal.refresh_list(),
)
pw.from_iterable_function(
self.tracker.refresh_missing_files,
None,
self.set_missing_count,
self.delete_modal.refresh_list,
)

def set_missing_count(self, count: int):
self.missing_count = count
def set_missing_count(self, count: int | None = None):
if count is not None:
self.missing_count = count
else:
self.missing_count = self.tracker.missing_files_count

if self.missing_count < 0:
self.search_button.setDisabled(True)
self.delete_button.setDisabled(True)
Expand Down
16 changes: 3 additions & 13 deletions tagstudio/src/qt/modals/merge_dupe_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@

import typing

from PySide6.QtCore import QObject, QThreadPool, Signal
from PySide6.QtCore import QObject, Signal
from src.core.library import Library
from src.core.utils.dupe_files import DupeRegistry
from src.qt.helpers.custom_runnable import CustomRunnable
from src.qt.helpers.function_iterator import FunctionIterator
from src.qt.widgets.progress import ProgressWidget

# Only import for type checking/autocompletion, will not be imported at runtime.
Expand All @@ -26,20 +24,12 @@ def __init__(self, library: "Library", driver: "QtDriver"):
self.tracker = DupeRegistry(library=self.lib)

def merge_entries(self):
iterator = FunctionIterator(self.tracker.merge_dupe_entries)

pw = ProgressWidget(
window_title="Merging Duplicate Entries",
label_text="",
label_text="Merging Duplicate Entries...",
cancel_button_text=None,
minimum=0,
maximum=self.tracker.groups_count,
)
pw.show()

iterator.value.connect(lambda x: pw.update_progress(x))
iterator.value.connect(lambda: (pw.update_label("Merging Duplicate Entries...")))

r = CustomRunnable(iterator.run)
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
QThreadPool.globalInstance().start(r)
pw.from_iterable_function(self.tracker.merge_dupe_entries, None, self.done.emit)
Loading

0 comments on commit 6e63b0e

Please sign in to comment.