Skip to content

Commit 385aaf4

Browse files
seakruegerCreepler13yedpodtrzitkoCyanVoxel
authored
feat: reimplement drag drop files (Port #153) (#528)
* 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 * Fix ruff formatting * Rename "override" button to "overwrite" --------- 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> * 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 <denis.schlichting03@gmail.com> Co-authored-by: yed podtrzitko <yedpodtrzitko@users.noreply.github.com> Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
1 parent a1daf5a commit 385aaf4

File tree

9 files changed

+361
-137
lines changed

9 files changed

+361
-137
lines changed

tagstudio/src/qt/modals/delete_unlinked.py

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import typing
66

7-
from PySide6.QtCore import Qt, QThreadPool, Signal
7+
from PySide6.QtCore import Qt, Signal
88
from PySide6.QtGui import QStandardItem, QStandardItemModel
99
from PySide6.QtWidgets import (
1010
QHBoxLayout,
@@ -15,8 +15,6 @@
1515
QWidget,
1616
)
1717
from src.core.utils.missing_files import MissingRegistry
18-
from src.qt.helpers.custom_runnable import CustomRunnable
19-
from src.qt.helpers.function_iterator import FunctionIterator
2018
from src.qt.widgets.progress import ProgressWidget
2119

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

7876
self.model.clear()
7977
for i in self.tracker.missing_files:
80-
self.model.appendRow(QStandardItem(str(i.path)))
78+
item = QStandardItem(str(i.path))
79+
item.setEditable(False)
80+
self.model.appendRow(item)
8181

8282
def delete_entries(self):
83+
def displayed_text(x):
84+
return f"Deleting {x}/{self.tracker.missing_files_count} Unlinked Entries"
85+
8386
pw = ProgressWidget(
8487
window_title="Deleting Entries",
8588
label_text="",
8689
cancel_button_text=None,
8790
minimum=0,
8891
maximum=self.tracker.missing_files_count,
8992
)
90-
pw.show()
91-
92-
iterator = FunctionIterator(self.tracker.execute_deletion)
93-
files_count = self.tracker.missing_files_count
94-
iterator.value.connect(
95-
lambda idx: (
96-
pw.update_progress(idx),
97-
pw.update_label(f"Deleting {idx}/{files_count} Unlinked Entries"),
98-
)
99-
)
10093

101-
r = CustomRunnable(iterator.run)
102-
QThreadPool.globalInstance().start(r)
103-
r.done.connect(
104-
lambda: (
105-
pw.hide(),
106-
pw.deleteLater(),
107-
self.done.emit(),
108-
)
109-
)
94+
pw.from_iterable_function(self.tracker.execute_deletion, displayed_text, self.done.emit)
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
# Licensed under the GPL-3.0 License.
2+
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
3+
4+
import enum
5+
import shutil
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
9+
import structlog
10+
from PySide6.QtCore import Qt, QUrl
11+
from PySide6.QtGui import QStandardItem, QStandardItemModel
12+
from PySide6.QtWidgets import (
13+
QHBoxLayout,
14+
QLabel,
15+
QListView,
16+
QPushButton,
17+
QVBoxLayout,
18+
QWidget,
19+
)
20+
from src.qt.widgets.progress import ProgressWidget
21+
22+
if TYPE_CHECKING:
23+
from src.qt.ts_qt import QtDriver
24+
25+
logger = structlog.get_logger(__name__)
26+
27+
28+
class DuplicateChoice(enum.StrEnum):
29+
SKIP = "Skipped"
30+
OVERWRITE = "Overwritten"
31+
RENAME = "Renamed"
32+
CANCEL = "Cancelled"
33+
34+
35+
class DropImportModal(QWidget):
36+
DUPE_NAME_LIMT: int = 5
37+
38+
def __init__(self, driver: "QtDriver"):
39+
super().__init__()
40+
41+
self.driver: QtDriver = driver
42+
43+
# Widget ======================
44+
self.setWindowTitle("Conflicting File(s)")
45+
self.setWindowModality(Qt.WindowModality.ApplicationModal)
46+
self.setMinimumSize(500, 400)
47+
self.root_layout = QVBoxLayout(self)
48+
self.root_layout.setContentsMargins(6, 6, 6, 6)
49+
50+
self.desc_widget = QLabel()
51+
self.desc_widget.setObjectName("descriptionLabel")
52+
self.desc_widget.setWordWrap(True)
53+
self.desc_widget.setText("The following files have filenames already exist in the library")
54+
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
55+
56+
# Duplicate File List ========
57+
self.list_view = QListView()
58+
self.model = QStandardItemModel()
59+
self.list_view.setModel(self.model)
60+
61+
# Buttons ====================
62+
self.button_container = QWidget()
63+
self.button_layout = QHBoxLayout(self.button_container)
64+
self.button_layout.setContentsMargins(6, 6, 6, 6)
65+
self.button_layout.addStretch(1)
66+
67+
self.skip_button = QPushButton()
68+
self.skip_button.setText("&Skip")
69+
self.skip_button.setDefault(True)
70+
self.skip_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.SKIP))
71+
self.button_layout.addWidget(self.skip_button)
72+
73+
self.overwrite_button = QPushButton()
74+
self.overwrite_button.setText("&Overwrite")
75+
self.overwrite_button.clicked.connect(
76+
lambda: self.begin_transfer(DuplicateChoice.OVERWRITE)
77+
)
78+
self.button_layout.addWidget(self.overwrite_button)
79+
80+
self.rename_button = QPushButton()
81+
self.rename_button.setText("&Rename")
82+
self.rename_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.RENAME))
83+
self.button_layout.addWidget(self.rename_button)
84+
85+
self.cancel_button = QPushButton()
86+
self.cancel_button.setText("&Cancel")
87+
self.cancel_button.clicked.connect(lambda: self.begin_transfer(DuplicateChoice.CANCEL))
88+
self.button_layout.addWidget(self.cancel_button)
89+
90+
# Layout =====================
91+
self.root_layout.addWidget(self.desc_widget)
92+
self.root_layout.addWidget(self.list_view)
93+
self.root_layout.addWidget(self.button_container)
94+
95+
def import_urls(self, urls: list[QUrl]):
96+
"""Add a colleciton of urls to the library."""
97+
self.files: list[Path] = []
98+
self.dirs_in_root: list[Path] = []
99+
self.duplicate_files: list[Path] = []
100+
101+
self.collect_files_to_import(urls)
102+
103+
if len(self.duplicate_files) > 0:
104+
self.ask_duplicates_choice()
105+
else:
106+
self.begin_transfer()
107+
108+
def collect_files_to_import(self, urls: list[QUrl]):
109+
"""Collect one or more files from drop event urls."""
110+
for url in urls:
111+
if not url.isLocalFile():
112+
continue
113+
114+
file = Path(url.toLocalFile())
115+
116+
if file.is_dir():
117+
for f in file.glob("**/*"):
118+
if f.is_dir():
119+
continue
120+
121+
self.files.append(f)
122+
if (self.driver.lib.library_dir / self._get_relative_path(file)).exists():
123+
self.duplicate_files.append(f)
124+
125+
self.dirs_in_root.append(file.parent)
126+
else:
127+
self.files.append(file)
128+
129+
if file.parent not in self.dirs_in_root:
130+
self.dirs_in_root.append(
131+
file.parent
132+
) # to create relative path of files not in folder
133+
134+
if (Path(self.driver.lib.library_dir) / file.name).exists():
135+
self.duplicate_files.append(file)
136+
137+
def ask_duplicates_choice(self):
138+
"""Display the message widgeth with a list of the duplicated files."""
139+
self.desc_widget.setText(
140+
f"The following {len(self.duplicate_files)} file(s) have filenames already exist in the library." # noqa: E501
141+
)
142+
143+
self.model.clear()
144+
for dupe in self.duplicate_files:
145+
item = QStandardItem(str(self._get_relative_path(dupe)))
146+
item.setEditable(False)
147+
self.model.appendRow(item)
148+
149+
self.driver.main_window.raise_()
150+
self.show()
151+
152+
def begin_transfer(self, choice: DuplicateChoice | None = None):
153+
"""Display a progress bar and begin copying files into library."""
154+
self.hide()
155+
self.choice: DuplicateChoice | None = choice
156+
logger.info("duplicated choice selected", choice=self.choice)
157+
if self.choice == DuplicateChoice.CANCEL:
158+
return
159+
160+
def displayed_text(x):
161+
text = (
162+
f"Importing New Files...\n{x[0] + 1} File{'s' if x[0] + 1 != 1 else ''} Imported."
163+
)
164+
if self.choice:
165+
text += f" {x[1]} {self.choice.value}"
166+
167+
return text
168+
169+
pw = ProgressWidget(
170+
window_title="Import Files",
171+
label_text="Importing New Files...",
172+
cancel_button_text=None,
173+
minimum=0,
174+
maximum=len(self.files),
175+
)
176+
177+
pw.from_iterable_function(
178+
self.copy_files,
179+
displayed_text,
180+
self.driver.add_new_files_callback,
181+
self.deleteLater,
182+
)
183+
184+
def copy_files(self):
185+
"""Copy files from original location to the library directory."""
186+
file_count = 0
187+
duplicated_files_progress = 0
188+
for file in self.files:
189+
if file.is_dir():
190+
continue
191+
192+
dest_file = self._get_relative_path(file)
193+
194+
if file in self.duplicate_files:
195+
duplicated_files_progress += 1
196+
if self.choice == DuplicateChoice.SKIP:
197+
file_count += 1
198+
continue
199+
elif self.choice == DuplicateChoice.RENAME:
200+
new_name = self._get_renamed_duplicate_filename(dest_file)
201+
dest_file = dest_file.with_name(new_name)
202+
203+
(self.driver.lib.library_dir / dest_file).parent.mkdir(parents=True, exist_ok=True)
204+
shutil.copyfile(file, self.driver.lib.library_dir / dest_file)
205+
206+
file_count += 1
207+
yield [file_count, duplicated_files_progress]
208+
209+
def _get_relative_path(self, path: Path) -> Path:
210+
for dir in self.dirs_in_root:
211+
if path.is_relative_to(dir):
212+
return path.relative_to(dir)
213+
return Path(path.name)
214+
215+
def _get_renamed_duplicate_filename(self, filepath: Path) -> str:
216+
index = 2
217+
o_filename = filepath.name
218+
219+
try:
220+
dot_idx = o_filename.index(".")
221+
except ValueError:
222+
dot_idx = len(o_filename)
223+
224+
while (self.driver.lib.library_dir / filepath).exists():
225+
filepath = filepath.with_name(
226+
o_filename[:dot_idx] + f" ({index})" + o_filename[dot_idx:]
227+
)
228+
index += 1
229+
return filepath.name

tagstudio/src/qt/modals/fix_unlinked.py

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@
55

66
import typing
77

8-
from PySide6.QtCore import Qt, QThreadPool
8+
from PySide6.QtCore import Qt
99
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
1010
from src.core.library import Library
1111
from src.core.utils.missing_files import MissingRegistry
12-
from src.qt.helpers.custom_runnable import CustomRunnable
13-
from src.qt.helpers.function_iterator import FunctionIterator
1412
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
1513
from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries
1614
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
@@ -85,7 +83,7 @@ def __init__(self, library: "Library", driver: "QtDriver"):
8583
self.delete_modal = DeleteUnlinkedEntriesModal(self.driver, self.tracker)
8684
self.delete_modal.done.connect(
8785
lambda: (
88-
self.set_missing_count(self.tracker.missing_files_count),
86+
self.set_missing_count(),
8987
# refresh the grid
9088
self.driver.filter_items(),
9189
)
@@ -125,23 +123,19 @@ def refresh_missing_files(self):
125123
maximum=self.lib.entries_count,
126124
)
127125

128-
pw.show()
129-
130-
iterator = FunctionIterator(self.tracker.refresh_missing_files)
131-
iterator.value.connect(lambda v: pw.update_progress(v + 1))
132-
r = CustomRunnable(iterator.run)
133-
QThreadPool.globalInstance().start(r)
134-
r.done.connect(
135-
lambda: (
136-
pw.hide(),
137-
pw.deleteLater(),
138-
self.set_missing_count(self.tracker.missing_files_count),
139-
self.delete_modal.refresh_list(),
140-
)
126+
pw.from_iterable_function(
127+
self.tracker.refresh_missing_files,
128+
None,
129+
self.set_missing_count,
130+
self.delete_modal.refresh_list,
141131
)
142132

143-
def set_missing_count(self, count: int):
144-
self.missing_count = count
133+
def set_missing_count(self, count: int | None = None):
134+
if count is not None:
135+
self.missing_count = count
136+
else:
137+
self.missing_count = self.tracker.missing_files_count
138+
145139
if self.missing_count < 0:
146140
self.search_button.setDisabled(True)
147141
self.delete_button.setDisabled(True)

tagstudio/src/qt/modals/merge_dupe_entries.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44

55
import typing
66

7-
from PySide6.QtCore import QObject, QThreadPool, Signal
7+
from PySide6.QtCore import QObject, Signal
88
from src.core.library import Library
99
from src.core.utils.dupe_files import DupeRegistry
10-
from src.qt.helpers.custom_runnable import CustomRunnable
11-
from src.qt.helpers.function_iterator import FunctionIterator
1210
from src.qt.widgets.progress import ProgressWidget
1311

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

2826
def merge_entries(self):
29-
iterator = FunctionIterator(self.tracker.merge_dupe_entries)
30-
3127
pw = ProgressWidget(
3228
window_title="Merging Duplicate Entries",
33-
label_text="",
29+
label_text="Merging Duplicate Entries...",
3430
cancel_button_text=None,
3531
minimum=0,
3632
maximum=self.tracker.groups_count,
3733
)
38-
pw.show()
39-
40-
iterator.value.connect(lambda x: pw.update_progress(x))
41-
iterator.value.connect(lambda: (pw.update_label("Merging Duplicate Entries...")))
4234

43-
r = CustomRunnable(iterator.run)
44-
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
45-
QThreadPool.globalInstance().start(r)
35+
pw.from_iterable_function(self.tracker.merge_dupe_entries, None, self.done.emit)

0 commit comments

Comments
 (0)