Skip to content

Commit 491ebb6

Browse files
committed
Add duplicate entry handling (Fix #179)
- Running "Fix Unlinked Entries" will no longer result in duplicate entries if the directory was refreshed after the original entries became unlinked. - A "Duplicate Entries" section is added to the "Fix Unlinked Entries" modal to help repair existing affected libraries.
1 parent 385b411 commit 491ebb6

File tree

7 files changed

+280
-326
lines changed

7 files changed

+280
-326
lines changed

tagstudio/src/core/library.py

Lines changed: 95 additions & 155 deletions
Large diffs are not rendered by default.

tagstudio/src/qt/modals/delete_unlinked.py

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def __init__(self, library: "Library", driver: "QtDriver"):
3232
super().__init__()
3333
self.lib = library
3434
self.driver = driver
35-
self.setWindowTitle(f"Delete Unlinked Entries")
35+
self.setWindowTitle("Delete Unlinked Entries")
3636
self.setWindowModality(Qt.WindowModality.ApplicationModal)
3737
self.setMinimumSize(500, 400)
3838
self.root_layout = QVBoxLayout(self)
@@ -81,20 +81,6 @@ def refresh_list(self):
8181
self.model.appendRow(QStandardItem(i))
8282

8383
def delete_entries(self):
84-
# pb = QProgressDialog('', None, 0, len(self.lib.missing_files))
85-
# # pb.setMaximum(len(self.lib.missing_files))
86-
# pb.setFixedSize(432, 112)
87-
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
88-
# pb.setWindowTitle('Deleting Entries')
89-
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
90-
# pb.show()
91-
92-
# r = CustomRunnable(lambda: self.lib.ref(pb))
93-
# r.done.connect(lambda: self.done.emit())
94-
# # r.done.connect(lambda: self.model.clear())
95-
# QThreadPool.globalInstance().start(r)
96-
# # r.run()
97-
9884
iterator = FunctionIterator(self.lib.remove_missing_files)
9985

10086
pw = ProgressWidget(
@@ -119,23 +105,3 @@ def delete_entries(self):
119105
r = CustomRunnable(lambda: iterator.run())
120106
QThreadPool.globalInstance().start(r)
121107
r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit()))
122-
123-
# def delete_entries_runnable(self):
124-
# deleted = []
125-
# for i, missing in enumerate(self.lib.missing_files):
126-
# # pb.setValue(i)
127-
# # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries')
128-
# try:
129-
# id = self.lib.get_entry_id_from_filepath(missing)
130-
# logging.info(f'Removing Entry ID {id}:\n\t{missing}')
131-
# self.lib.remove_entry(id)
132-
# self.driver.purge_item_from_navigation(ItemType.ENTRY, id)
133-
# deleted.append(missing)
134-
# except KeyError:
135-
# logging.info(
136-
# f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.')
137-
# yield i
138-
# for d in deleted:
139-
# self.lib.missing_files.remove(d)
140-
# # self.driver.filter_items('')
141-
# # self.done.emit()

tagstudio/src/qt/modals/fix_unlinked.py

Lines changed: 124 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -6,80 +6,95 @@
66
import logging
77
import typing
88

9-
from PySide6.QtCore import QThread, Qt, QThreadPool
9+
from PySide6.QtCore import Qt, QThreadPool
1010
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
1111

1212
from src.core.library import Library
1313
from src.qt.helpers.function_iterator import FunctionIterator
1414
from src.qt.helpers.custom_runnable import CustomRunnable
1515
from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal
1616
from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries
17+
from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries
1718
from src.qt.widgets.progress import ProgressWidget
1819

1920
# Only import for type checking/autocompletion, will not be imported at runtime.
2021
if typing.TYPE_CHECKING:
2122
from src.qt.ts_qt import QtDriver
2223

2324

24-
ERROR = f"[ERROR]"
25-
WARNING = f"[WARNING]"
26-
INFO = f"[INFO]"
25+
ERROR = "[ERROR]"
26+
WARNING = "[WARNING]"
27+
INFO = "[INFO]"
2728

2829
logging.basicConfig(format="%(message)s", level=logging.INFO)
2930

3031

3132
class FixUnlinkedEntriesModal(QWidget):
32-
# done = Signal(int)
3333
def __init__(self, library: "Library", driver: "QtDriver"):
3434
super().__init__()
3535
self.lib = library
3636
self.driver = driver
37-
self.count = -1
38-
self.setWindowTitle(f"Fix Unlinked Entries")
37+
self.missing_count = -1
38+
self.dupe_count = -1
39+
self.setWindowTitle("Fix Unlinked Entries")
3940
self.setWindowModality(Qt.WindowModality.ApplicationModal)
4041
self.setMinimumSize(400, 300)
4142
self.root_layout = QVBoxLayout(self)
4243
self.root_layout.setContentsMargins(6, 6, 6, 6)
4344

44-
self.desc_widget = QLabel()
45-
self.desc_widget.setObjectName("descriptionLabel")
46-
self.desc_widget.setWordWrap(True)
47-
self.desc_widget.setStyleSheet(
48-
# 'background:blue;'
49-
"text-align:left;"
50-
# 'font-weight:bold;'
51-
# 'font-size:14px;'
52-
# 'padding-top: 6px'
53-
""
45+
self.unlinked_desc_widget = QLabel()
46+
self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel")
47+
self.unlinked_desc_widget.setWordWrap(True)
48+
self.unlinked_desc_widget.setStyleSheet("text-align:left;")
49+
self.unlinked_desc_widget.setText(
50+
"""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired."""
5451
)
55-
self.desc_widget.setText("""Each library entry is linked to a file in one of your directories. If a file linked to an entry is moved or deleted outside of TagStudio, it is then considered unlinked.
56-
Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""")
57-
self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
58-
59-
self.missing_count = QLabel()
60-
self.missing_count.setObjectName("missingCountLabel")
61-
self.missing_count.setStyleSheet(
62-
# 'background:blue;'
63-
# 'text-align:center;'
64-
"font-weight:bold;"
65-
"font-size:14px;"
66-
# 'padding-top: 6px'
67-
""
52+
53+
self.dupe_desc_widget = QLabel()
54+
self.dupe_desc_widget.setObjectName("dupeDescriptionLabel")
55+
self.dupe_desc_widget.setWordWrap(True)
56+
self.dupe_desc_widget.setStyleSheet("text-align:left;")
57+
self.dupe_desc_widget.setText(
58+
"""Duplicate entries are defined as multiple entries which point to the same file on disk. Merging these will combine the tags and metadata from all duplicates into a single consolidated entry. These are not to be confused with "duplicate files", which are duplicates of your files themselves outside of TagStudio."""
59+
)
60+
61+
self.missing_count_label = QLabel()
62+
self.missing_count_label.setObjectName("missingCountLabel")
63+
self.missing_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
64+
self.missing_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
65+
66+
self.dupe_count_label = QLabel()
67+
self.dupe_count_label.setObjectName("dupeCountLabel")
68+
self.dupe_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;")
69+
self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
70+
71+
self.refresh_unlinked_button = QPushButton()
72+
self.refresh_unlinked_button.setText("&Refresh All")
73+
self.refresh_unlinked_button.clicked.connect(
74+
lambda: self.refresh_missing_files()
6875
)
69-
self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter)
70-
# self.missing_count.setText('Missing Files: N/A')
7176

72-
self.refresh_button = QPushButton()
73-
self.refresh_button.setText("&Refresh")
74-
self.refresh_button.clicked.connect(lambda: self.refresh_missing_files())
77+
self.merge_class = MergeDuplicateEntries(self.lib, self.driver)
78+
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
7579

7680
self.search_button = QPushButton()
7781
self.search_button.setText("&Search && Relink")
78-
self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver)
79-
self.relink_class.done.connect(lambda: self.refresh_missing_files())
80-
self.relink_class.done.connect(lambda: self.driver.update_thumbs())
82+
self.relink_class.done.connect(
83+
lambda: self.refresh_and_repair_dupe_entries(self.merge_class)
84+
)
8185
self.search_button.clicked.connect(lambda: self.relink_class.repair_entries())
8286

87+
self.refresh_dupe_button = QPushButton()
88+
self.refresh_dupe_button.setText("Refresh Duplicate Entries")
89+
self.refresh_dupe_button.clicked.connect(lambda: self.refresh_dupe_entries())
90+
91+
self.merge_dupe_button = QPushButton()
92+
self.merge_dupe_button.setText("&Merge Duplicate Entries")
93+
self.merge_class.done.connect(lambda: self.set_dupe_count(-1))
94+
self.merge_class.done.connect(lambda: self.set_missing_count(-1))
95+
self.merge_class.done.connect(lambda: self.driver.filter_items())
96+
self.merge_dupe_button.clicked.connect(lambda: self.merge_class.merge_entries())
97+
8398
self.manual_button = QPushButton()
8499
self.manual_button.setText("&Manual Relink")
85100

@@ -92,65 +107,46 @@ def __init__(self, library: "Library", driver: "QtDriver"):
92107
self.delete_button.setText("De&lete Unlinked Entries")
93108
self.delete_button.clicked.connect(lambda: self.delete_modal.show())
94109

95-
# self.combo_box = QComboBox()
96-
# self.combo_box.setEditable(False)
97-
# # self.combo_box.setMaxVisibleItems(5)
98-
# self.combo_box.setStyleSheet('combobox-popup:0;')
99-
# self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
100-
# for df in self.lib.default_fields:
101-
# self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})')
102-
103110
self.button_container = QWidget()
104111
self.button_layout = QHBoxLayout(self.button_container)
105112
self.button_layout.setContentsMargins(6, 6, 6, 6)
106113
self.button_layout.addStretch(1)
107114

108115
self.done_button = QPushButton()
109116
self.done_button.setText("&Done")
110-
# self.save_button.setAutoDefault(True)
111117
self.done_button.setDefault(True)
112118
self.done_button.clicked.connect(self.hide)
113-
# self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
114-
# self.save_button.clicked.connect(lambda: save_callback(widget.get_content()))
115119
self.button_layout.addWidget(self.done_button)
116120

117-
# self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex()))
118-
119-
# self.done.connect(lambda x: callback(x))
120-
121-
self.root_layout.addWidget(self.desc_widget)
122-
self.root_layout.addWidget(self.missing_count)
123-
self.root_layout.addWidget(self.refresh_button)
121+
self.root_layout.addWidget(self.missing_count_label)
122+
self.root_layout.addWidget(self.unlinked_desc_widget)
123+
self.root_layout.addWidget(self.refresh_unlinked_button)
124124
self.root_layout.addWidget(self.search_button)
125125
self.manual_button.setHidden(True)
126126
self.root_layout.addWidget(self.manual_button)
127127
self.root_layout.addWidget(self.delete_button)
128-
# self.root_layout.setStretch(1,2)
129128
self.root_layout.addStretch(1)
129+
self.root_layout.addWidget(self.dupe_count_label)
130+
self.root_layout.addWidget(self.dupe_desc_widget)
131+
self.root_layout.addWidget(self.refresh_dupe_button)
132+
self.root_layout.addWidget(self.merge_dupe_button)
133+
self.root_layout.addStretch(2)
130134
self.root_layout.addWidget(self.button_container)
131135

132-
self.set_missing_count(self.count)
136+
self.set_missing_count(self.missing_count)
137+
self.set_dupe_count(self.dupe_count)
133138

134139
def refresh_missing_files(self):
135-
logging.info(f"Start RMF: {QThread.currentThread()}")
136-
# pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries))
137-
# pb.setFixedSize(432, 112)
138-
# pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
139-
# pb.setWindowTitle('Scanning Library')
140-
# pb.setWindowModality(Qt.WindowModality.ApplicationModal)
141-
# pb.show()
142-
143140
iterator = FunctionIterator(self.lib.refresh_missing_files)
144141
pw = ProgressWidget(
145142
window_title="Scanning Library",
146-
label_text=f"Scanning Library for Unlinked Entries...",
143+
label_text="Scanning Library for Unlinked Entries...",
147144
cancel_button_text=None,
148145
minimum=0,
149146
maximum=len(self.lib.entries),
150147
)
151148
pw.show()
152149
iterator.value.connect(lambda v: pw.update_progress(v + 1))
153-
# rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}'))
154150
r = CustomRunnable(lambda: iterator.run())
155151
QThreadPool.globalInstance().start(r)
156152
r.done.connect(
@@ -159,30 +155,76 @@ def refresh_missing_files(self):
159155
pw.deleteLater(),
160156
self.set_missing_count(len(self.lib.missing_files)),
161157
self.delete_modal.refresh_list(),
158+
self.refresh_dupe_entries(),
162159
)
163160
)
164161

165-
# r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v)))
166-
# r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list()))
167-
# QThreadPool.globalInstance().start(r)
168-
# # r.run()
169-
# pass
162+
def refresh_dupe_entries(self):
163+
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
164+
pw = ProgressWidget(
165+
window_title="Scanning Library",
166+
label_text="Scanning Library for Duplicate Entries...",
167+
cancel_button_text=None,
168+
minimum=0,
169+
maximum=len(self.lib.entries),
170+
)
171+
pw.show()
172+
iterator.value.connect(lambda v: pw.update_progress(v + 1))
173+
r = CustomRunnable(lambda: iterator.run())
174+
QThreadPool.globalInstance().start(r)
175+
r.done.connect(
176+
lambda: (
177+
pw.hide(),
178+
pw.deleteLater(),
179+
self.set_dupe_count(len(self.lib.dupe_entries)),
180+
)
181+
)
170182

171-
# def update_scan_value(self, pb:QProgressDialog, value=int):
172-
# # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...')
173-
# pb.setValue(value)
183+
def refresh_and_repair_dupe_entries(self, merge_class: MergeDuplicateEntries):
184+
iterator = FunctionIterator(self.lib.refresh_dupe_entries)
185+
pw = ProgressWidget(
186+
window_title="Scanning Library",
187+
label_text="Scanning Library for Duplicate Entries...",
188+
cancel_button_text=None,
189+
minimum=0,
190+
maximum=len(self.lib.entries),
191+
)
192+
pw.show()
193+
iterator.value.connect(lambda v: pw.update_progress(v + 1))
194+
r = CustomRunnable(lambda: iterator.run())
195+
QThreadPool.globalInstance().start(r)
196+
r.done.connect(
197+
lambda: (
198+
pw.hide(),
199+
pw.deleteLater(),
200+
self.set_dupe_count(len(self.lib.dupe_entries)),
201+
merge_class.merge_entries(),
202+
)
203+
)
174204

175205
def set_missing_count(self, count: int):
176-
self.count = count
177-
if self.count < 0:
206+
self.missing_count = count
207+
if self.missing_count < 0:
178208
self.search_button.setDisabled(True)
179209
self.delete_button.setDisabled(True)
180-
self.missing_count.setText(f"Unlinked Entries: N/A")
181-
elif self.count == 0:
210+
self.missing_count_label.setText("Unlinked Entries: N/A")
211+
elif self.missing_count == 0:
182212
self.search_button.setDisabled(True)
183213
self.delete_button.setDisabled(True)
184-
self.missing_count.setText(f"Unlinked Entries: {count}")
214+
self.missing_count_label.setText(f"Unlinked Entries: {count}")
185215
else:
186216
self.search_button.setDisabled(False)
187217
self.delete_button.setDisabled(False)
188-
self.missing_count.setText(f"Unlinked Entries: {count}")
218+
self.missing_count_label.setText(f"Unlinked Entries: {count}")
219+
220+
def set_dupe_count(self, count: int):
221+
self.dupe_count = count
222+
if self.dupe_count < 0:
223+
self.dupe_count_label.setText("Duplicate Entries: N/A")
224+
self.merge_dupe_button.setDisabled(True)
225+
elif self.dupe_count == 0:
226+
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
227+
self.merge_dupe_button.setDisabled(True)
228+
else:
229+
self.dupe_count_label.setText(f"Duplicate Entries: {count}")
230+
self.merge_dupe_button.setDisabled(False)

0 commit comments

Comments
 (0)