From 66ec0913b60a195fe3843941c4c00021bf97e023 Mon Sep 17 00:00:00 2001 From: Travis Abendshien Date: Mon, 20 May 2024 17:43:25 -0700 Subject: [PATCH] Revert "Add duplicate entry handling (Fix #179)" This reverts commit 491ebb6714b2245e9de20c122e2f0fa9fd6211b0. --- tagstudio/src/core/library.py | 250 +++++++++++------- tagstudio/src/qt/modals/delete_unlinked.py | 36 ++- tagstudio/src/qt/modals/fix_unlinked.py | 204 ++++++-------- tagstudio/src/qt/modals/merge_dupe_entries.py | 46 ---- tagstudio/src/qt/modals/relink_unlinked.py | 40 ++- tagstudio/src/qt/ts_qt.py | 18 +- tagstudio/src/qt/widgets/preview_panel.py | 10 +- 7 files changed, 325 insertions(+), 279 deletions(-) delete mode 100644 tagstudio/src/qt/modals/merge_dupe_entries.py diff --git a/tagstudio/src/core/library.py b/tagstudio/src/core/library.py index ad9d8c2c..d3121020 100644 --- a/tagstudio/src/core/library.py +++ b/tagstudio/src/core/library.py @@ -86,7 +86,7 @@ class Entry: return self.__str__() def __eq__(self, __value: object) -> bool: - # __value = cast(Self, object) + __value = cast(Self, object) if os.name == "nt": return ( int(self.id) == int(__value.id) #type: ignore @@ -328,7 +328,7 @@ class Library: def __init__(self) -> None: # Library Info ========================================================= - self.library_dir: Path = None + self.library_dir: str = None # Entries ============================================================== # List of every Entry object. @@ -439,7 +439,7 @@ class Library: {"id": 30, "name": "Comments", "type": "text_box"}, ] - def create_library(self, path: Path) -> int: + def create_library(self, path) -> int: """ Creates a TagStudio library in the given directory.\n Return Codes:\n @@ -447,6 +447,8 @@ class Library: 2: File creation error """ + path = os.path.normpath(path).rstrip("\\") + # If '.TagStudio' is included in the path, trim the path up to it. if TS_FOLDER_NAME in str(path): # TODO: Native Path method instead of this casting. @@ -467,12 +469,12 @@ class Library: def verify_ts_folders(self) -> None: """Verifies/creates folders required by TagStudio.""" - full_ts_path = Path() / self.library_dir / TS_FOLDER_NAME - full_backup_path = ( - Path() / self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME + full_ts_path = os.path.normpath(f"{self.library_dir}/{TS_FOLDER_NAME}") + full_backup_path = os.path.normpath( + f"{self.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}" ) - full_collage_path = ( - Path() / self.library_dir / TS_FOLDER_NAME / COLLAGE_FOLDER_NAME + full_collage_path = os.path.normpath( + f"{self.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}" ) if not os.path.isdir(full_ts_path): @@ -521,7 +523,7 @@ class Library: encoding="utf-8", ) as file: json_dump: JsonLibary = ujson.load(file) - self.library_dir = Path(path) + self.library_dir = str(path) self.verify_ts_folders() major, minor, patch = json_dump["ts-version"].split(".") @@ -677,7 +679,6 @@ class Library: ) self.entries.append(e) self._map_entry_id_to_index(e, -1) - end_time = time.time() logging.info( f"[LIBRARY] Entries loaded in {(end_time - start_time):.3f} seconds" @@ -739,9 +740,19 @@ class Library: """Maps a full filepath to its corresponding Entry's ID.""" self.filename_to_entry_id_map.clear() for entry in self.entries: - self.filename_to_entry_id_map[Path() / entry.path / entry.filename] = ( - entry.id - ) + if os.name == "nt": + # print(str(os.path.normpath( + # f'{entry.path}/{entry.filename}')).lower().lstrip('\\').lstrip('/')) + self.filename_to_entry_id_map[ + str(os.path.normpath(f"{entry.path}/{entry.filename}")) + .lower() + .lstrip("\\") + .lstrip("/") + ] = entry.id + else: + self.filename_to_entry_id_map[ + str(os.path.normpath(f"{entry.path}/{entry.filename}")).lstrip("/") + ] = entry.id # def _map_filenames_to_entry_ids(self): # """Maps the file paths of entries to their index in the library list.""" @@ -891,19 +902,23 @@ class Library: # - Files without library entries # for type in TYPES: start_time = time.time() - for f in self.library_dir.glob("**/*"): + for f in glob.glob(self.library_dir + "/**/*", recursive=True): # p = Path(os.path.normpath(f)) if ( - "$RECYCLE.BIN" not in f.parts - and TS_FOLDER_NAME not in f.parts - and "tagstudio_thumbs" not in f.parts - and not f.is_dir() + "$RECYCLE.BIN" not in f + and TS_FOLDER_NAME not in f + and "tagstudio_thumbs" not in f + and not os.path.isdir(f) ): - if f.suffix not in self.ignored_extensions: + if os.path.splitext(f)[1][1:].lower() not in self.ignored_extensions: self.dir_file_count += 1 - file = f.relative_to(self.library_dir) + file = str(os.path.relpath(f, self.library_dir)) + try: - _ = self.filename_to_entry_id_map[file] + if os.name == "nt": + _ = self.filename_to_entry_id_map[file.lower()] + else: + _ = self.filename_to_entry_id_map[file] except KeyError: # print(file) self.files_not_in_library.append(file) @@ -922,7 +937,9 @@ class Library: try: self.files_not_in_library = sorted( self.files_not_in_library, - key=lambda t: -os.stat((Path() / self.library_dir / t)).st_ctime, + key=lambda t: -os.stat( + os.path.normpath(self.library_dir + "/" + t) + ).st_ctime, ) except (FileExistsError, FileNotFoundError): print( @@ -953,7 +970,12 @@ class Library: # Step [1/2]: # Remove this Entry from the Entries list. entry = self.get_entry(entry_id) - path = Path() / entry.path / entry.filename + path = ( + str(os.path.normpath(f"{entry.path}/{entry.filename}")) + .lstrip("\\") + .lstrip("/") + ) + path = path.lower() if os.name == "nt" else path # logging.info(f'Removing path: {path}') del self.filename_to_entry_id_map[path] @@ -979,34 +1001,51 @@ class Library: `dupe_entries = tuple(int, list[int])` """ + # self.dupe_entries.clear() + # known_files: set = set() + # for entry in self.entries: + # full_path = os.path.normpath(f'{self.library_dir}/{entry.path}/{entry.filename}') + # if full_path in known_files: + # self.dupe_entries.append(full_path) + # else: + # known_files.add(full_path) + self.dupe_entries.clear() - registered: dict = {} # string: list[int] - - # Registered: filename : list[ALL entry IDs pointing to this filename] - # Dupe Entries: primary ID : list of [every OTHER entry ID pointing] - - for i, e in enumerate(self.entries): - file: Path = Path() / e.path / e.filename - # If this unique filepath has not been marked as checked, - if not registered.get(file, None): - # Register the filepath as having been checked, and include - # its entry ID as the first entry in the corresponding list. - registered[file] = [e.id] - # Else if the filepath is already been seen in another entry, - else: - # Add this new entry ID to the list of entry ID(s) pointing to - # the same file. - registered[file].append(e.id) - yield i - 1 # The -1 waits for the next step to finish - - for k, v in registered.items(): - if len(v) > 1: - self.dupe_entries.append((v[0], v[1:])) - # logging.info(f"DUPLICATE FOUND: {(v[0], v[1:])}") - # for id in v: - # logging.info(f"\t{(Path()/self.get_entry(id).path/self.get_entry(id).filename)}") - - yield len(self.entries) + checked = set() + remaining: list[Entry] = list(self.entries) + for p, entry_p in enumerate(self.entries, start=0): + if p not in checked: + matched: list[int] = [] + for c, entry_c in enumerate(remaining, start=0): + if os.name == "nt": + if ( + entry_p.path.lower() == entry_c.path.lower() + and entry_p.filename.lower() == entry_c.filename.lower() + and c != p + ): + matched.append(c) + checked.add(c) + else: + if ( + entry_p.path == entry_c.path + and entry_p.filename == entry_c.filename + and c != p + ): + matched.append(c) + checked.add(c) + if matched: + self.dupe_entries.append((p, matched)) + sys.stdout.write( + f"\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has Duplicate(s): {matched}" + ) + sys.stdout.flush() + else: + sys.stdout.write( + f"\r[LIBRARY] Entry [{p}/{len(self.entries)-1}]: Has No Duplicates" + ) + sys.stdout.flush() + checked.add(p) + print("") def merge_dupe_entries(self): """ @@ -1016,36 +1055,35 @@ class Library: `dupe_entries = tuple(int, list[int])` """ - logging.info("[LIBRARY] Mirroring Duplicate Entries...") - id_to_entry_map: dict = {} - + print("[LIBRARY] Mirroring Duplicate Entries...") for dupe in self.dupe_entries: - # Store the id to entry relationship as the library one is about to - # be destroyed. - # NOTE: This is not a good solution, but will be upended by the - # database migration soon anyways. - for id in dupe[1]: - id_to_entry_map[id] = self.get_entry(id) self.mirror_entry_fields([dupe[0]] + dupe[1]) - logging.info( + # print('Consolidating Entries...') + # for dupe in self.dupe_entries: + # for index in dupe[1]: + # print(f'Consolidating Duplicate: {(self.entries[index].path + os.pathsep + self.entries[index].filename)}') + # self.entries.remove(self.entries[index]) + # self._map_filenames_to_entry_indices() + + print( "[LIBRARY] Consolidating Entries... (This may take a while for larger libraries)" ) - for i, dupe in enumerate(self.dupe_entries): - for id in dupe[1]: - # NOTE: Instead of using self.remove_entry(id), I'm bypassing it - # because it's currently inefficient in how it needs to remap - # every ID to every list index. I'm recreating the steps it - # takes but in a batch-friendly way here. - # NOTE: Couldn't use get_entry(id) because that relies on the - # entry's index in the list, which is currently being messed up. - logging.info(f"[LIBRARY] Removing Unneeded Entry {id}") - self.entries.remove(id_to_entry_map[id]) - yield i - 1 # The -1 waits for the next step to finish - - self._entry_id_to_index_map.clear() - for i, e in enumerate(self.entries, start=0): - self._map_entry_id_to_index(e, i) + unique: list[Entry] = [] + for i, e in enumerate(self.entries): + if e not in unique: + unique.append(e) + # print(f'[{i}/{len(self.entries)}] Appending: {(e.path + os.pathsep + e.filename)[0:32]}...') + sys.stdout.write( + f"\r[LIBRARY] [{i}/{len(self.entries)}] Appending Unique Entry..." + ) + else: + sys.stdout.write( + f"\r[LIBRARY] [{i}/{len(self.entries)}] Consolidating Duplicate: {(e.path + os.pathsep + e.filename)[0:]}..." + ) + print("") + # [unique.append(x) for x in self.entries if x not in unique] + self.entries = unique self._map_filenames_to_entry_ids() def refresh_dupe_files(self, results_filepath): @@ -1055,9 +1093,9 @@ class Library: by a DupeGuru results file. """ full_results_path = ( - Path() / self.library_dir / results_filepath + os.path.normpath(f"{self.library_dir}/{results_filepath}") if self.library_dir not in results_filepath - else Path(results_filepath) + else os.path.normpath(f"{results_filepath}") ) if os.path.exists(full_results_path): self.dupe_files.clear() @@ -1083,15 +1121,26 @@ class Library: ) for match in matches: # print(f'MATCHED ({match[2]}%): \n {files[match[0]]} \n-> {files[match[1]]}') - file_1 = Path() / files[match[0]] / self.library_dir - file_2 = Path() / files[match[1]] / self.library_dir - if ( - file_1 in self.filename_to_entry_id_map.keys() - and file_2 in self.filename_to_entry_id_map.keys() - ): - self.dupe_files.append( - (files[match[0]], files[match[1]], match[2]) - ) + if os.name == "nt": + file_1 = str(os.path.relpath(files[match[0]], self.library_dir)) + file_2 = str(os.path.relpath(files[match[1]], self.library_dir)) + if ( + file_1.lower() in self.filename_to_entry_id_map.keys() + and file_2.lower() in self.filename_to_entry_id_map.keys() + ): + self.dupe_files.append( + (files[match[0]], files[match[1]], match[2]) + ) + else: + if ( + file_1 in self.filename_to_entry_id_map.keys() + and file_2 in self.filename_to_entry_id_map.keys() + ): + self.dupe_files.append( + (files[match[0]], files[match[1]], match[2]) + ) + # self.dupe_files.append((files[match[0]], files[match[1]], match[2])) + print("") for dupe in self.dupe_files: @@ -1167,16 +1216,19 @@ class Library: print(f"[LIBRARY] Fixed {self.get_entry(id).filename}") # (int, str) - # Consolidate new matches with existing unlinked entries. - self.refresh_dupe_entries() - if self.dupe_entries: - self.merge_dupe_entries() - - # Remap filenames to entry IDs. self._map_filenames_to_entry_ids() # TODO - the type here doesnt match but I cant reproduce calling this self.remove_missing_matches(fixed_indices) + # for i in fixed_indices: + # # print(json_dump[i]) + # del self.missing_matches[i] + + # with open(matched_json_filepath, "w") as outfile: + # outfile.flush() + # json.dump({}, outfile, indent=4) + # print(f'Re-saved to disk at {matched_json_filepath}') + def _match_missing_file(self, file: str) -> list[str]: """ Tries to find missing entry files within the library directory. @@ -1204,7 +1256,7 @@ class Library: # matches[file].append(new_path) print( - f"[LIBRARY] MATCH: {file} \n\t-> {Path()/self.library_dir/new_path/tail}\n" + f'[LIBRARY] MATCH: {file} \n\t-> {os.path.normpath(self.library_dir + "/" + new_path + "/" + tail)}\n' ) if not matches: @@ -1293,12 +1345,20 @@ class Library: return None # @deprecated('Use new Entry ID system.') - def get_entry_id_from_filepath(self, filename: Path): + def get_entry_id_from_filepath(self, filename): """Returns an Entry ID given the full filepath it points to.""" try: if self.entries: + if os.name == "nt": + return self.filename_to_entry_id_map[ + str( + os.path.normpath( + os.path.relpath(filename, self.library_dir) + ) + ).lower() + ] return self.filename_to_entry_id_map[ - Path(filename).relative_to(self.library_dir) + str(os.path.normpath(os.path.relpath(filename, self.library_dir))) ] except: return -1 diff --git a/tagstudio/src/qt/modals/delete_unlinked.py b/tagstudio/src/qt/modals/delete_unlinked.py index a7e2ad04..3787b18b 100644 --- a/tagstudio/src/qt/modals/delete_unlinked.py +++ b/tagstudio/src/qt/modals/delete_unlinked.py @@ -32,7 +32,7 @@ class DeleteUnlinkedEntriesModal(QWidget): super().__init__() self.lib = library self.driver = driver - self.setWindowTitle("Delete Unlinked Entries") + self.setWindowTitle(f"Delete Unlinked Entries") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(500, 400) self.root_layout = QVBoxLayout(self) @@ -81,6 +81,20 @@ class DeleteUnlinkedEntriesModal(QWidget): self.model.appendRow(QStandardItem(i)) def delete_entries(self): + # pb = QProgressDialog('', None, 0, len(self.lib.missing_files)) + # # pb.setMaximum(len(self.lib.missing_files)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Deleting Entries') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() + + # r = CustomRunnable(lambda: self.lib.ref(pb)) + # r.done.connect(lambda: self.done.emit()) + # # r.done.connect(lambda: self.model.clear()) + # QThreadPool.globalInstance().start(r) + # # r.run() + iterator = FunctionIterator(self.lib.remove_missing_files) pw = ProgressWidget( @@ -105,3 +119,23 @@ class DeleteUnlinkedEntriesModal(QWidget): r = CustomRunnable(lambda: iterator.run()) QThreadPool.globalInstance().start(r) r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) + + # def delete_entries_runnable(self): + # deleted = [] + # for i, missing in enumerate(self.lib.missing_files): + # # pb.setValue(i) + # # pb.setLabelText(f'Deleting {i}/{len(self.lib.missing_files)} Unlinked Entries') + # try: + # id = self.lib.get_entry_id_from_filepath(missing) + # logging.info(f'Removing Entry ID {id}:\n\t{missing}') + # self.lib.remove_entry(id) + # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) + # deleted.append(missing) + # except KeyError: + # logging.info( + # f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') + # yield i + # for d in deleted: + # self.lib.missing_files.remove(d) + # # self.driver.filter_items('') + # # self.done.emit() diff --git a/tagstudio/src/qt/modals/fix_unlinked.py b/tagstudio/src/qt/modals/fix_unlinked.py index e02415b7..0e27f85f 100644 --- a/tagstudio/src/qt/modals/fix_unlinked.py +++ b/tagstudio/src/qt/modals/fix_unlinked.py @@ -6,7 +6,7 @@ import logging import typing -from PySide6.QtCore import Qt, QThreadPool +from PySide6.QtCore import QThread, Qt, QThreadPool from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton from src.core.library import Library @@ -14,7 +14,6 @@ from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable from src.qt.modals.delete_unlinked import DeleteUnlinkedEntriesModal from src.qt.modals.relink_unlinked import RelinkUnlinkedEntries -from src.qt.modals.merge_dupe_entries import MergeDuplicateEntries from src.qt.widgets.progress import ProgressWidget # Only import for type checking/autocompletion, will not be imported at runtime. @@ -22,79 +21,65 @@ if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver -ERROR = "[ERROR]" -WARNING = "[WARNING]" -INFO = "[INFO]" +ERROR = f"[ERROR]" +WARNING = f"[WARNING]" +INFO = f"[INFO]" logging.basicConfig(format="%(message)s", level=logging.INFO) class FixUnlinkedEntriesModal(QWidget): + # done = Signal(int) def __init__(self, library: "Library", driver: "QtDriver"): super().__init__() self.lib = library self.driver = driver - self.missing_count = -1 - self.dupe_count = -1 - self.setWindowTitle("Fix Unlinked Entries") + self.count = -1 + self.setWindowTitle(f"Fix Unlinked Entries") self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setMinimumSize(400, 300) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 6, 6, 6) - self.unlinked_desc_widget = QLabel() - self.unlinked_desc_widget.setObjectName("unlinkedDescriptionLabel") - self.unlinked_desc_widget.setWordWrap(True) - self.unlinked_desc_widget.setStyleSheet("text-align:left;") - self.unlinked_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. Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""" + self.desc_widget = QLabel() + self.desc_widget.setObjectName("descriptionLabel") + self.desc_widget.setWordWrap(True) + self.desc_widget.setStyleSheet( + # 'background:blue;' + "text-align:left;" + # 'font-weight:bold;' + # 'font-size:14px;' + # 'padding-top: 6px' + "" ) + 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. + Unlinked entries may be automatically relinked via searching your directories, manually relinked by the user, or deleted if desired.""") + self.desc_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.dupe_desc_widget = QLabel() - self.dupe_desc_widget.setObjectName("dupeDescriptionLabel") - self.dupe_desc_widget.setWordWrap(True) - self.dupe_desc_widget.setStyleSheet("text-align:left;") - self.dupe_desc_widget.setText( - """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.""" + self.missing_count = QLabel() + self.missing_count.setObjectName("missingCountLabel") + self.missing_count.setStyleSheet( + # 'background:blue;' + # 'text-align:center;' + "font-weight:bold;" + "font-size:14px;" + # 'padding-top: 6px' + "" ) + self.missing_count.setAlignment(Qt.AlignmentFlag.AlignCenter) + # self.missing_count.setText('Missing Files: N/A') - self.missing_count_label = QLabel() - self.missing_count_label.setObjectName("missingCountLabel") - self.missing_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;") - self.missing_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.dupe_count_label = QLabel() - self.dupe_count_label.setObjectName("dupeCountLabel") - self.dupe_count_label.setStyleSheet("font-weight:bold;" "font-size:14px;") - self.dupe_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.refresh_unlinked_button = QPushButton() - self.refresh_unlinked_button.setText("&Refresh All") - self.refresh_unlinked_button.clicked.connect( - lambda: self.refresh_missing_files() - ) - - self.merge_class = MergeDuplicateEntries(self.lib, self.driver) - self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver) + self.refresh_button = QPushButton() + self.refresh_button.setText("&Refresh") + self.refresh_button.clicked.connect(lambda: self.refresh_missing_files()) self.search_button = QPushButton() self.search_button.setText("&Search && Relink") - self.relink_class.done.connect( - lambda: self.refresh_and_repair_dupe_entries(self.merge_class) - ) + self.relink_class = RelinkUnlinkedEntries(self.lib, self.driver) + self.relink_class.done.connect(lambda: self.refresh_missing_files()) + self.relink_class.done.connect(lambda: self.driver.update_thumbs()) self.search_button.clicked.connect(lambda: self.relink_class.repair_entries()) - self.refresh_dupe_button = QPushButton() - self.refresh_dupe_button.setText("Refresh Duplicate Entries") - self.refresh_dupe_button.clicked.connect(lambda: self.refresh_dupe_entries()) - - self.merge_dupe_button = QPushButton() - self.merge_dupe_button.setText("&Merge Duplicate Entries") - self.merge_class.done.connect(lambda: self.set_dupe_count(-1)) - self.merge_class.done.connect(lambda: self.set_missing_count(-1)) - self.merge_class.done.connect(lambda: self.driver.filter_items()) - self.merge_dupe_button.clicked.connect(lambda: self.merge_class.merge_entries()) - self.manual_button = QPushButton() self.manual_button.setText("&Manual Relink") @@ -107,6 +92,14 @@ class FixUnlinkedEntriesModal(QWidget): self.delete_button.setText("De&lete Unlinked Entries") self.delete_button.clicked.connect(lambda: self.delete_modal.show()) + # self.combo_box = QComboBox() + # self.combo_box.setEditable(False) + # # self.combo_box.setMaxVisibleItems(5) + # self.combo_box.setStyleSheet('combobox-popup:0;') + # self.combo_box.view().setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + # for df in self.lib.default_fields: + # self.combo_box.addItem(f'{df["name"]} ({df["type"].replace("_", " ").title()})') + self.button_container = QWidget() self.button_layout = QHBoxLayout(self.button_container) self.button_layout.setContentsMargins(6, 6, 6, 6) @@ -114,39 +107,50 @@ class FixUnlinkedEntriesModal(QWidget): self.done_button = QPushButton() self.done_button.setText("&Done") + # self.save_button.setAutoDefault(True) self.done_button.setDefault(True) self.done_button.clicked.connect(self.hide) + # self.done_button.clicked.connect(lambda: self.done.emit(self.combo_box.currentIndex())) + # self.save_button.clicked.connect(lambda: save_callback(widget.get_content())) self.button_layout.addWidget(self.done_button) - self.root_layout.addWidget(self.missing_count_label) - self.root_layout.addWidget(self.unlinked_desc_widget) - self.root_layout.addWidget(self.refresh_unlinked_button) + # self.returnPressed.connect(lambda: self.done.emit(self.combo_box.currentIndex())) + + # self.done.connect(lambda x: callback(x)) + + self.root_layout.addWidget(self.desc_widget) + self.root_layout.addWidget(self.missing_count) + self.root_layout.addWidget(self.refresh_button) self.root_layout.addWidget(self.search_button) self.manual_button.setHidden(True) self.root_layout.addWidget(self.manual_button) self.root_layout.addWidget(self.delete_button) + # self.root_layout.setStretch(1,2) self.root_layout.addStretch(1) - self.root_layout.addWidget(self.dupe_count_label) - self.root_layout.addWidget(self.dupe_desc_widget) - self.root_layout.addWidget(self.refresh_dupe_button) - self.root_layout.addWidget(self.merge_dupe_button) - self.root_layout.addStretch(2) self.root_layout.addWidget(self.button_container) - self.set_missing_count(self.missing_count) - self.set_dupe_count(self.dupe_count) + self.set_missing_count(self.count) def refresh_missing_files(self): + logging.info(f"Start RMF: {QThread.currentThread()}") + # pb = QProgressDialog(f'Scanning Library for Unlinked Entries...', None, 0,len(self.lib.entries)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Scanning Library') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() + iterator = FunctionIterator(self.lib.refresh_missing_files) pw = ProgressWidget( window_title="Scanning Library", - label_text="Scanning Library for Unlinked Entries...", + label_text=f"Scanning Library for Unlinked Entries...", cancel_button_text=None, minimum=0, maximum=len(self.lib.entries), ) pw.show() iterator.value.connect(lambda v: pw.update_progress(v + 1)) + # rmf.value.connect(lambda v: pw.update_label(f'Progress: {v}')) r = CustomRunnable(lambda: iterator.run()) QThreadPool.globalInstance().start(r) r.done.connect( @@ -155,76 +159,30 @@ class FixUnlinkedEntriesModal(QWidget): pw.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list(), - self.refresh_dupe_entries(), ) ) - def refresh_dupe_entries(self): - iterator = FunctionIterator(self.lib.refresh_dupe_entries) - pw = ProgressWidget( - window_title="Scanning Library", - label_text="Scanning Library for Duplicate Entries...", - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.entries), - ) - pw.show() - iterator.value.connect(lambda v: pw.update_progress(v + 1)) - r = CustomRunnable(lambda: iterator.run()) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.set_dupe_count(len(self.lib.dupe_entries)), - ) - ) + # r = CustomRunnable(lambda: self.lib.refresh_missing_files(lambda v: self.update_scan_value(pb, v))) + # r.done.connect(lambda: (pb.hide(), pb.deleteLater(), self.set_missing_count(len(self.lib.missing_files)), self.delete_modal.refresh_list())) + # QThreadPool.globalInstance().start(r) + # # r.run() + # pass - def refresh_and_repair_dupe_entries(self, merge_class: MergeDuplicateEntries): - iterator = FunctionIterator(self.lib.refresh_dupe_entries) - pw = ProgressWidget( - window_title="Scanning Library", - label_text="Scanning Library for Duplicate Entries...", - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.entries), - ) - pw.show() - iterator.value.connect(lambda v: pw.update_progress(v + 1)) - r = CustomRunnable(lambda: iterator.run()) - QThreadPool.globalInstance().start(r) - r.done.connect( - lambda: ( - pw.hide(), - pw.deleteLater(), - self.set_dupe_count(len(self.lib.dupe_entries)), - merge_class.merge_entries(), - ) - ) + # def update_scan_value(self, pb:QProgressDialog, value=int): + # # pb.setLabelText(f'Scanning Library for Unlinked Entries ({value}/{len(self.lib.entries)})...') + # pb.setValue(value) def set_missing_count(self, count: int): - self.missing_count = count - if self.missing_count < 0: + self.count = count + if self.count < 0: self.search_button.setDisabled(True) self.delete_button.setDisabled(True) - self.missing_count_label.setText("Unlinked Entries: N/A") - elif self.missing_count == 0: + self.missing_count.setText(f"Unlinked Entries: N/A") + elif self.count == 0: self.search_button.setDisabled(True) self.delete_button.setDisabled(True) - self.missing_count_label.setText(f"Unlinked Entries: {count}") + self.missing_count.setText(f"Unlinked Entries: {count}") else: self.search_button.setDisabled(False) self.delete_button.setDisabled(False) - self.missing_count_label.setText(f"Unlinked Entries: {count}") - - def set_dupe_count(self, count: int): - self.dupe_count = count - if self.dupe_count < 0: - self.dupe_count_label.setText("Duplicate Entries: N/A") - self.merge_dupe_button.setDisabled(True) - elif self.dupe_count == 0: - self.dupe_count_label.setText(f"Duplicate Entries: {count}") - self.merge_dupe_button.setDisabled(True) - else: - self.dupe_count_label.setText(f"Duplicate Entries: {count}") - self.merge_dupe_button.setDisabled(False) + self.missing_count.setText(f"Unlinked Entries: {count}") diff --git a/tagstudio/src/qt/modals/merge_dupe_entries.py b/tagstudio/src/qt/modals/merge_dupe_entries.py deleted file mode 100644 index 249e6d11..00000000 --- a/tagstudio/src/qt/modals/merge_dupe_entries.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import typing - -from PySide6.QtCore import QObject, Signal, QThreadPool - -from src.core.library import Library -from src.qt.helpers.function_iterator import FunctionIterator -from src.qt.helpers.custom_runnable import CustomRunnable -from src.qt.widgets.progress import ProgressWidget - -# Only import for type checking/autocompletion, will not be imported at runtime. -if typing.TYPE_CHECKING: - from src.qt.ts_qt import QtDriver - - -class MergeDuplicateEntries(QObject): - done = Signal() - - def __init__(self, library: "Library", driver: "QtDriver"): - super().__init__() - self.lib = library - self.driver = driver - - def merge_entries(self): - iterator = FunctionIterator(self.lib.merge_dupe_entries) - - pw = ProgressWidget( - window_title="Merging Duplicate Entries", - label_text="", - cancel_button_text=None, - minimum=0, - maximum=len(self.lib.dupe_entries), - ) - pw.show() - - iterator.value.connect(lambda x: pw.update_progress(x)) - iterator.value.connect( - lambda: (pw.update_label("Merging Duplicate Entries...")) - ) - - r = CustomRunnable(lambda: iterator.run()) - r.done.connect(lambda: (pw.hide(), pw.deleteLater(), self.done.emit())) - QThreadPool.globalInstance().start(r) diff --git a/tagstudio/src/qt/modals/relink_unlinked.py b/tagstudio/src/qt/modals/relink_unlinked.py index 15af5cd3..1adffaf1 100644 --- a/tagstudio/src/qt/modals/relink_unlinked.py +++ b/tagstudio/src/qt/modals/relink_unlinked.py @@ -26,6 +26,20 @@ class RelinkUnlinkedEntries(QObject): self.fixed = 0 def repair_entries(self): + # pb = QProgressDialog('', None, 0, len(self.lib.missing_files)) + # # pb.setMaximum(len(self.lib.missing_files)) + # pb.setFixedSize(432, 112) + # pb.setWindowFlags(pb.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + # pb.setWindowTitle('Relinking Entries') + # pb.setWindowModality(Qt.WindowModality.ApplicationModal) + # pb.show() + + # r = CustomRunnable(lambda: self.repair_entries_runnable(pb)) + # r.done.connect(lambda: self.done.emit()) + # # r.done.connect(lambda: self.model.clear()) + # QThreadPool.globalInstance().start(r) + # # r.run() + iterator = FunctionIterator(self.lib.fix_missing_files) pw = ProgressWidget( @@ -35,7 +49,6 @@ class RelinkUnlinkedEntries(QObject): minimum=0, maximum=len(self.lib.missing_files), ) - pw.show() iterator.value.connect(lambda x: pw.update_progress(x[0] + 1)) @@ -47,6 +60,7 @@ class RelinkUnlinkedEntries(QObject): ), ) ) + # iterator.value.connect(lambda x: self.driver.purge_item_from_navigation(ItemType.ENTRY, x[1])) r = CustomRunnable(lambda: iterator.run()) r.done.connect( @@ -59,3 +73,27 @@ class RelinkUnlinkedEntries(QObject): def reset_fixed(self): self.fixed = 0 + + # def repair_entries_runnable(self, pb: QProgressDialog): + # fixed = 0 + # for i in self.lib.fix_missing_files(): + # if i[1]: + # fixed += 1 + # pb.setValue(i[0]) + # pb.setLabelText(f'Attempting to Relink {i[0]+1}/{len(self.lib.missing_files)} Entries, {fixed} Successfully Relinked') + + # for i, missing in enumerate(self.lib.missing_files): + # pb.setValue(i) + # pb.setLabelText(f'Relinking {i}/{len(self.lib.missing_files)} Unlinked Entries') + # self.lib.fix_missing_files() + # try: + # id = self.lib.get_entry_id_from_filepath(missing) + # logging.info(f'Removing Entry ID {id}:\n\t{missing}') + # self.lib.remove_entry(id) + # self.driver.purge_item_from_navigation(ItemType.ENTRY, id) + # deleted.append(missing) + # except KeyError: + # logging.info( + # f'{ERROR} \"{id}\" was reported as missing, but is not in the file_to_entry_id map.') + # for d in deleted: + # self.lib.missing_files.remove(d) diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 37af0dd4..e140ccc8 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -224,10 +224,8 @@ class QtDriver(QObject): thread.start() def open_library_from_dialog(self): - dir = Path( - QFileDialog.getExistingDirectory( - None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly - ) + dir = QFileDialog.getExistingDirectory( + None, "Open/Create Library", "/", QFileDialog.ShowDirsOnly ) if dir not in (None, ""): self.open_library(dir) @@ -522,7 +520,7 @@ class QtDriver(QObject): int(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter), QColor("#9782ff"), ) - self.open_library(Path(lib)) + self.open_library(lib) if self.args.ci: # gracefully terminate the app in CI environment @@ -1388,7 +1386,7 @@ class QtDriver(QObject): self.settings.endGroup() self.settings.sync() - def open_library(self, path: Path): + def open_library(self, path): """Opens a TagStudio library.""" if self.lib.library_dir: self.save_library() @@ -1397,6 +1395,14 @@ class QtDriver(QObject): self.main_window.statusbar.showMessage(f"Opening Library {str(path)}", 3) return_code = self.lib.open_library(path) if return_code == 1: + # if self.args.external_preview: + # self.init_external_preview() + + # if len(self.lib.entries) <= 1000: + # print(f'{INFO} Checking for missing files in Library \'{self.lib.library_dir}\'...') + # self.lib.refresh_missing_files() + # title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\'' + # self.main_window.setWindowTitle(title_text) pass else: diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index 3c883f9a..049115af 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -4,7 +4,6 @@ import logging import os -from pathlib import Path import time import typing from datetime import datetime as dt @@ -305,7 +304,7 @@ class PreviewPanel(QWidget): button.setObjectName(f"path{item_key}") def open_library_button_clicked(path): - return lambda: self.driver.open_library(Path(path)) + return lambda: self.driver.open_library(path) button.clicked.connect(open_library_button_clicked(full_val)) set_button_style(button) @@ -529,13 +528,10 @@ class PreviewPanel(QWidget): if not image: raise UnidentifiedImageError - except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{extension.upper()}") - logging.info( - f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})" - ) except ( UnidentifiedImageError, + FileNotFoundError, + cv2.error, DecompressionBombError, ) as e: self.dimensions_label.setText(