diff --git a/bagheeraview.py b/bagheeraview.py index 4857957..fcbe027 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -1037,7 +1037,7 @@ class MainWindow(QMainWindow): self._group_info_cache = {} self._visible_paths_cache = None # Cache for visible image paths self._path_to_model_index = {} - self._paths_being_modified_by_app = set() # For ignoring FS events + self._paths_being_modified_by_app = set() # For ignoring FS events # Keep references to open viewers to manage their lifecycle self.viewers = [] @@ -1844,7 +1844,8 @@ class MainWindow(QMainWindow): self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) return - # Por defecto usamos el modo optimizado (incremental) para no repetir comparaciones + # Por defecto usamos el modo optimizado (incremental) para no repetir + # comparaciones self.start_duplicate_detection(force_full=False, custom_paths=paths) def _gather_files_for_duplicates(self): @@ -4879,24 +4880,28 @@ class MainWindow(QMainWindow): new_size = new_stat.st_size # Find old data from internal list - old_item_data = next((item for item in self.found_items_data if item[0] == path), None) + old_item_data = next((item for item in self.found_items_data + if item[0] == path), None) old_mtime = old_item_data[2] if old_item_data else 0 - old_size = os.path.getsize(path) if old_item_data else 0 # Re-read size from disk for comparison + # Re-read size from disk for comparison + old_size = os.path.getsize(path) if old_item_data else 0 if new_size == old_size and new_mtime != old_mtime: # Likely metadata-only change (size unchanged, mtime changed) res = load_common_metadata(path) - self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating, - inode=new_stat.st_ino, dev=new_stat.st_dev) + self._update_internal_data( + path, mtime=new_mtime, tags=res.tags, rating=res.rating, + inode=new_stat.st_ino, dev=new_stat.st_dev) self.proxy_model.add_to_cache(path, res.tags) - self.thumbnail_view.viewport().update() # Force repaint + self.thumbnail_view.viewport().update() # Force repaint self.status_lbl.setText(f"Metadata updated: {os.path.basename(path)}") else: # Content or size changed, invalidate thumbnail and rebuild view self.cache.invalidate_path(path) - res = load_common_metadata(path) # Re-read metadata as well - self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating, - inode=new_stat.st_ino, dev=new_stat.st_dev) + res = load_common_metadata(path) # Re-read metadata as well + self._update_internal_data( + path, mtime=new_mtime, tags=res.tags, rating=res.rating, + inode=new_stat.st_ino, dev=new_stat.st_dev) self.proxy_model.add_to_cache(path, res.tags) self.rebuild_view() self.status_lbl.setText(f"File modified: {os.path.basename(path)}") @@ -4912,8 +4917,10 @@ class MainWindow(QMainWindow): self._paths_being_modified_by_app.add(parent_path) # Schedule removal after a delay to allow all FS events to propagate - QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(abs_path)) - QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(parent_path)) + QTimer.singleShot( + 1000, lambda: self._paths_being_modified_by_app.discard(abs_path)) + QTimer.singleShot( + 1000, lambda: self._paths_being_modified_by_app.discard(parent_path)) def on_fs_watcher_status_changed(self, is_monitoring): """Updates the UI indicator for the FileSystemWatcher.""" diff --git a/duplicatecache.py b/duplicatecache.py index 0e56822..8bfb316 100644 --- a/duplicatecache.py +++ b/duplicatecache.py @@ -24,11 +24,14 @@ from constants import ( logger = logging.getLogger(__name__) # Result structure for duplicate detection -DuplicateResult = collections.namedtuple('DuplicateResult', ['path1', 'path2', 'hash_value', 'is_exception', 'similarity', 'timestamp']) +DuplicateResult = collections.namedtuple( + 'DuplicateResult', + ['path1', 'path2', 'hash_value', 'is_exception', 'similarity', 'timestamp']) class BKTree: - """A Burkhard-Keller tree for efficient similarity searching using Hamming distance.""" + """A Burkhard-Keller tree for efficient similarity searching using Hamming + distance.""" def __init__(self, distance_func): self.distance_func = distance_func self.tree = None @@ -210,7 +213,8 @@ class DuplicateCache(QObject): return None, 0, None with QWriteLocker(self._hash_cache_lock): - self._hash_cache[(dev_id, inode_key_bytes)] = (hash_str, mtime, path_str) + self._hash_cache[(dev_id, inode_key_bytes)] = ( + hash_str, mtime, path_str) return hash_str, mtime, path_str return None, 0, None @@ -225,7 +229,8 @@ class DuplicateCache(QObject): return hash_value return None - def add_hash_for_path(self, path, hash_value, mtime, dev_id=None, inode_key_bytes=None): + def add_hash_for_path(self, + path, hash_value, mtime, dev_id=None, inode_key_bytes=None): if dev_id is None or inode_key_bytes is None: dev_id, inode_key_bytes = self._get_inode_info(path) if not inode_key_bytes or not self._lmdb_env: @@ -264,8 +269,10 @@ class DuplicateCache(QObject): # Also remove any exceptions involving this path if clear_relationships: - self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._exceptions_db) - self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._pending_db) + self._remove_pair_entries_for_path( + dev_id, inode_key_bytes, self._exceptions_db) + self._remove_pair_entries_for_path( + dev_id, inode_key_bytes, self._pending_db) return True def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2): @@ -280,7 +287,9 @@ class DuplicateCache(QObject): return None return self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2) - def mark_as_exception(self, path1, path2, is_exception=True, similarity=None, timestamp=None): + def mark_as_exception(self, + path1, path2, is_exception=True, similarity=None, + timestamp=None): if not self._lmdb_env: return False @@ -323,8 +332,10 @@ class DuplicateCache(QObject): with self._lmdb_env.begin(write=False) as txn: return txn.get(exception_key, db=self._exceptions_db) is not None - def _remove_pair_entries_for_path(self, target_dev, target_inode, db_handle, txn=None): - """Removes all entries involving a specific (dev, inode) pair from a pair-based DB.""" + def _remove_pair_entries_for_path(self, + target_dev, target_inode, db_handle, txn=None): + """Removes all entries involving a specific (dev, inode) pair from a pair-based + DB.""" if not self._lmdb_env: return @@ -336,8 +347,10 @@ class DuplicateCache(QObject): for key_bytes, _ in cursor: key_str = key_bytes.decode('utf-8') parts = key_str.split('-') - if len(parts) < 4: continue - dev1, inode1_hex, dev2, inode2_hex = int(parts[0]), parts[1], int(parts[2]), parts[3] + if len(parts) < 4: + continue + dev1, inode1_hex, dev2, inode2_hex = int( + parts[0]), parts[1], int(parts[2]), parts[3] if (dev1 == target_dev and inode1_hex == target_inode_hex) or \ (dev2 == target_dev and inode2_hex == target_inode_hex): keys_to_delete.append(key_bytes) @@ -351,7 +364,8 @@ class DuplicateCache(QObject): with self._lmdb_env.begin(write=True) as t: do_remove(t) - def mark_as_pending(self, path1, path2, is_pending=True, similarity=None, timestamp=None): + def mark_as_pending(self, + path1, path2, is_pending=True, similarity=None, timestamp=None): """Marks a pair as pending review.""" if not self._lmdb_env or self._pending_db is None: return False @@ -392,7 +406,8 @@ class DuplicateCache(QObject): sim = int(parts[2]) if len(parts) > 2 and parts[2] else None ts = int(parts[3]) if len(parts) > 3 else 0 if os.path.exists(p1) and os.path.exists(p2): - results.append(DuplicateResult(p1, p2, None, False, sim, ts)) + results.append( + DuplicateResult(p1, p2, None, False, sim, ts)) else: keys_to_delete.append(key) except Exception: @@ -404,7 +419,8 @@ class DuplicateCache(QObject): with self._lmdb_env.begin(write=True) as txn: for k in keys_to_delete: txn.delete(k, db=self._pending_db) - logger.info(f"Cleaned up {len(keys_to_delete)} invalid pending duplicates (files deleted externally)") + logger.info(f"Cleaned up {len(keys_to_delete)} invalid " + "pending duplicates (files deleted externally)") except Exception as e: logger.error(f"Error cleaning up pending duplicates from DB: {e}") @@ -436,23 +452,28 @@ class DuplicateCache(QObject): if len(parts) > 3: ts = int(parts[3]) else: - ts = int(os.path.getmtime(p1)) if os.path.exists(p1) else 0 + ts = int(os.path.getmtime(p1)) \ + if os.path.exists(p1) else 0 if not p1 or not p2: # Legacy format fallback: lookup paths in hash db key_str = key_bytes.decode('utf-8') kp = key_str.split('-') if len(kp) == 4: - k1, k2 = f"{kp[0]}-{kp[1]}".encode(), f"{kp[2]}-{kp[3]}".encode() - v1, v2 = txn.get(k1, db=self._hash_db), txn.get(k2, db=self._hash_db) + k1, k2 = f"{kp[0]}-{kp[1]}".encode(), + f"{kp[2]}-{kp[3]}".encode() + v1, v2 = txn.get(k1, db=self._hash_db), \ + txn.get(k2, db=self._hash_db) if v1 and v2: - # Format is hash|mtime|path|dist... path is always index 2 + # Format is hash|mtime|path|dist... path is always + # index 2 p1 = v1.decode('utf-8').split('|')[2] p2 = v2.decode('utf-8').split('|')[2] if p1 and p2: if os.path.exists(p1) and os.path.exists(p2): - results.append(DuplicateResult(p1, p2, None, True, sim, ts)) + results.append( + DuplicateResult(p1, p2, None, True, sim, ts)) except Exception: continue return results @@ -484,11 +505,13 @@ class DuplicateCache(QObject): with self._lmdb_env.begin(write=True) as txn: for k in keys_to_delete: txn.delete(k, db=self._hash_db) - logger.info(f"Cleaned up {len(keys_to_delete)} stale hash entries (files deleted externally)") + logger.info(f"Cleaned up {len(keys_to_delete)} stale hash " + "entries (files deleted externally)") return len(keys_to_delete) def get_all_hashes_with_paths(self): - """Retrieves all hashes from the database along with their associated paths and inode info.""" + """Retrieves all hashes from the database along with their associated paths and + inode info.""" # hash_value -> [(path, dev_id, inode_key_bytes)] all_hashes = collections.defaultdict(list) if not self._lmdb_env: @@ -527,7 +550,8 @@ class DuplicateCache(QObject): if not old_inode_key_bytes or not new_inode_key_bytes or not self._lmdb_env: return False - # If the (dev, inode) pair is the same, only the path in the value needs updating. + # If the (dev, inode) pair is the same, only the path in the value needs + # updating. # This happens if the file is renamed within the same filesystem. if (old_dev, old_inode_key_bytes) == (new_dev, new_inode_key_bytes): hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes) @@ -543,8 +567,10 @@ class DuplicateCache(QObject): # 3. Add a new entry with the new (dev, inode) and path, using the old hash. hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes) if hash_value: - self.remove_hash_for_path(old_path) # This removes the old (dev, inode) entry - self.add_hash_for_path(new_path, hash_value, mtime) # Adds new (dev, inode) entry + # This removes the old (dev, inode) entry + self.remove_hash_for_path(old_path) + # Adds new (dev, inode) entry + self.add_hash_for_path(new_path, hash_value, mtime) self._update_pair_paths(old_path, new_path, self._pending_db) return True return False @@ -573,7 +599,9 @@ class DuplicateDetector(QThread): duplicates_found = Signal(list) # List of DuplicateResult detection_finished = Signal() - def __init__(self, paths_to_scan, duplicate_cache, pool_manager, method="histogram_hashing", threshold=90, force_full=False): + def __init__(self, + paths_to_scan, duplicate_cache, pool_manager, + method="histogram_hashing", threshold=90, force_full=False): super().__init__() self.paths_to_scan = paths_to_scan self.duplicate_cache = duplicate_cache @@ -585,17 +613,19 @@ class DuplicateDetector(QThread): def stop(self): self._is_running = False - self.wait() # Add this line + self.wait() # Add this line def run(self): total_files = len(self.paths_to_scan) found_duplicates = [] - unique_duplicate_pairs = set() # To store frozenset((path1, path2)) for uniqueness + # To store frozenset((path1, path2)) for uniqueness + unique_duplicate_pairs = set() last_update_time = 0 pool = self.pool_manager.get_pool() - # 1. Load existing pending duplicates from cache to avoid recalculation (unless force_full) + # 1. Load existing pending duplicates from cache to avoid recalculation (unless + # force_full) if not self.force_full: pending = self.duplicate_cache.get_all_pending_duplicates() for p in pending: @@ -606,7 +636,10 @@ class DuplicateDetector(QThread): # Convert similarity threshold (percentage) to Hamming distance distance_threshold = int(MAX_DHASH_DISTANCE * (100 - self.threshold) / 100) - logger.info(f"Duplicate detection: Method={self.method}, Similarity Threshold={self.threshold}%, Hamming Distance Threshold={distance_threshold}") + logger.info( + f"Duplicate detection: Method={self.method}, " + f"Similarity Threshold={self.threshold}%, Hamming " + f"Distance Threshold={distance_threshold}") # 2. Phase 1: Hash Collection (Parallelized) path_to_hash = {} @@ -645,7 +678,8 @@ class DuplicateDetector(QThread): break current_batch = paths_to_hash_parallel[i : i + batch_size] for p_data in current_batch: - pool.start(HashWorker(p_data[0], self, new_hashes, results_mutex, sem)) + pool.start(HashWorker( + p_data[0], self, new_hashes, results_mutex, sem)) for _ in range(len(current_batch)): while not sem.tryAcquire(1, 100): @@ -655,7 +689,9 @@ class DuplicateDetector(QThread): break processed_hashing += 1 if time.perf_counter() - last_update_time > 0.05: - self.progress_update.emit(processed_hashing, total_files * 2, UITexts.DUPLICATE_MSG_HASHING.format(filename="...")) + self.progress_update.emit( + processed_hashing, total_files * 2, + UITexts.DUPLICATE_MSG_HASHING.format(filename="...")) last_update_time = time.perf_counter() for p, mtime, dev, inode in paths_to_hash_parallel: @@ -670,7 +706,9 @@ class DuplicateDetector(QThread): return # Signal phase transition to exactly 50% - self.progress_update.emit(total_files, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) + self.progress_update.emit( + total_files, total_files * 2, + UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) # 3. Phase 2: Comparison (Optimized with BK-Tree) hash_map = collections.defaultdict(list) @@ -684,9 +722,12 @@ class DuplicateDetector(QThread): if self.force_full or p in dirty_paths: dirty_hashes_objs.add(h_obj) - # Optimization: Only query the tree for hashes associated with new or modified files. - # This finds pairs (Dirty, Clean) and (Dirty, Dirty). (Clean, Clean) were handled in previous runs. - hashes_to_query = list(dirty_hashes_objs) if not self.force_full else list(hash_map.keys()) + # Optimization: Only query the tree for hashes associated with new or modified + # files. + # This finds pairs (Dirty, Clean) and (Dirty, Dirty). (Clean, Clean) were + # handled in previous runs. + hashes_to_query = list(dirty_hashes_objs) \ + if not self.force_full else list(hash_map.keys()) total_queries = len(hashes_to_query) for i, h1 in enumerate(hashes_to_query): @@ -697,8 +738,11 @@ class DuplicateDetector(QThread): if time.perf_counter() - last_update_time > 0.1: # Scale Phase 2 progress to the 50%-100% range - phase2_progress = int(((i + 1) / total_queries) * total_files) if total_queries > 0 else total_files - self.progress_update.emit(total_files + phase2_progress, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) + phase2_progress = int(((i + 1) / total_queries) * total_files) \ + if total_queries > 0 else total_files + self.progress_update.emit( + total_files + phase2_progress, total_files * 2, + UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) last_update_time = time.perf_counter() # Query tree for similar hashes @@ -713,7 +757,8 @@ class DuplicateDetector(QThread): continue # Optimization: Skip pair if BOTH were already verified - if not self.force_full and p1 not in dirty_paths and p2 not in dirty_paths: + if not self.force_full \ + and p1 not in dirty_paths and p2 not in dirty_paths: continue canonical = frozenset((p1, p2)) @@ -726,7 +771,8 @@ class DuplicateDetector(QThread): res = DuplicateResult(p1, p2, str(h1), False, sim, ts) found_duplicates.append(res) unique_duplicate_pairs.add(canonical) - self.duplicate_cache.mark_as_pending(p1, p2, True, similarity=sim, timestamp=ts) + self.duplicate_cache.mark_as_pending( + p1, p2, True, similarity=sim, timestamp=ts) self.duplicates_found.emit(found_duplicates) self.detection_finished.emit() diff --git a/duplicatedialog.py b/duplicatedialog.py index addd3d4..e747c4d 100644 --- a/duplicatedialog.py +++ b/duplicatedialog.py @@ -5,11 +5,11 @@ from PySide6.QtWidgets import ( QSplitter, QWidget, QMessageBox, QApplication, QMenu, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView ) -from PySide6.QtGui import QIcon, QImage, QDesktopServices +from PySide6.QtGui import QIcon, QImageReader, QImage, QDesktopServices from PySide6.QtCore import Qt, QTimer, QUrl from imageviewer import ImagePane -from propertiesdialog import PropertiesDialog from constants import APP_CONFIG, UITexts +from propertiesdialog import PropertiesDialog class DuplicateManagerDialog(QDialog): @@ -26,6 +26,7 @@ class DuplicateManagerDialog(QDialog): self.active_pane = None self.current_dup_pair = None # Stores the current DuplicateResult object self.panes_linked = True # Default to linked + self._user_link_preference = True # Persiste la intención del usuario self._is_syncing = False # Guard to prevent recursion during synchronization self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE) @@ -37,7 +38,8 @@ class DuplicateManagerDialog(QDialog): if self.main_win and hasattr(self.main_win, 'fs_watcher'): self.main_win.fs_watcher.file_deleted.connect( self._on_file_deleted_externally) - self.main_win.fs_watcher.file_moved.connect(self._on_file_moved_externally) + self.main_win.fs_watcher.file_moved.connect( + self._on_file_moved_externally) if self.duplicates: self.table_widget.setCurrentCell(0, 0) @@ -59,7 +61,8 @@ class DuplicateManagerDialog(QDialog): self.table_widget = QTableWidget() if self.review_mode: - self.table_widget.setColumnCount(3) + columns = 3 + self.table_widget.setColumnCount(columns) self.table_widget.setHorizontalHeaderLabels( [UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN]) self.table_widget.horizontalHeader().setSectionResizeMode( @@ -69,9 +72,10 @@ class DuplicateManagerDialog(QDialog): self.table_widget.horizontalHeader().setSectionResizeMode( 2, QHeaderView.Stretch) else: - self.table_widget.setColumnCount(2) + columns = 2 + self.table_widget.setColumnCount(columns) self.table_widget.setHorizontalHeaderLabels( - ["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica + ["%", UITexts.CONTEXT_MENU_OPEN]) self.table_widget.horizontalHeader().setSectionResizeMode( 0, QHeaderView.ResizeToContents) self.table_widget.horizontalHeader().setSectionResizeMode( @@ -103,18 +107,22 @@ class DuplicateManagerDialog(QDialog): button_widget = QWidget() btn_layout = QHBoxLayout(button_widget) - self.btn_del_left = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_LEFT) + self.btn_del_left = QPushButton( + QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_LEFT) self.btn_del_left.clicked.connect(self._delete_left) - self.btn_del_right = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT) + self.btn_del_right = QPushButton( + QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT) self.btn_del_right.clicked.connect(self._delete_right) - self.btn_link_panes = QPushButton(QIcon.fromTheme("object-link"), UITexts.VIEWER_MENU_LINK_PANES) + self.btn_link_panes = QPushButton( + QIcon.fromTheme("object-link"), UITexts.VIEWER_MENU_LINK_PANES) self.btn_link_panes.setCheckable(True) self.btn_link_panes.setChecked(self.panes_linked) self.btn_link_panes.clicked.connect(self._toggle_link_panes) - self.btn_keep_both = QPushButton(QIcon.fromTheme("emblem-important"), UITexts.DUPLICATE_KEEP_BOTH) + self.btn_keep_both = QPushButton( + QIcon.fromTheme("emblem-important"), UITexts.DUPLICATE_KEEP_BOTH) self.btn_keep_both.clicked.connect(self._keep_both) self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP) @@ -135,7 +143,9 @@ class DuplicateManagerDialog(QDialog): self.similarity_lbl = QLabel() self.similarity_lbl.setAlignment(Qt.AlignCenter) self.similarity_lbl.setMinimumHeight(30) - self.similarity_lbl.setStyleSheet("font-weight: bold; color: #f39c12; font-size: 15px; background-color: #222; border: 1px solid #444; border-radius: 4px;") + self.similarity_lbl.setStyleSheet( + "font-weight: bold; color: #f39c12; font-size: 15px; " + "background-color: #222; border: 1px solid #444; border-radius: 4px;") main_right_layout = QVBoxLayout() main_right_layout.addWidget(self.comparison_widget, 1) @@ -156,8 +166,10 @@ class DuplicateManagerDialog(QDialog): """Disconnects signals and performs cleanup when closing.""" if self.main_win and hasattr(self.main_win, 'fs_watcher'): try: - self.main_win.fs_watcher.file_deleted.disconnect(self._on_file_deleted_externally) - self.main_win.fs_watcher.file_moved.disconnect(self._on_file_moved_externally) + self.main_win.fs_watcher.file_deleted.disconnect( + self._on_file_deleted_externally) + self.main_win.fs_watcher.file_moved.disconnect( + self._on_file_moved_externally) except (RuntimeError, TypeError): pass @@ -340,21 +352,25 @@ class DuplicateManagerDialog(QDialog): if self.review_mode: # Column 0: Ignored Date ts = dup.timestamp if hasattr(dup, 'timestamp') and dup.timestamp else 0 - date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "-" + date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") \ + if ts else "-" date_item = QTableWidgetItem(date_str) - date_item.setData(Qt.UserRole, i) # Store original index here for _load_pair + # Store original index here for _load_pair + date_item.setData(Qt.UserRole, i) date_item.setTextAlignment(Qt.AlignCenter) self.table_widget.setItem(row, 0, date_item) col_offset = 1 else: col_offset = 0 - # Columna similarity (usamos DisplayRole con int para que ordene numéricamente) + # Columna similarity (usamos DisplayRole con int para que ordene + # numéricamente) sim_item = QTableWidgetItem() - sim_item.setData(Qt.DisplayRole, dup.similarity if dup.similarity is not None else 0) + sim_item.setData(Qt.DisplayRole, dup.similarity + if dup.similarity is not None else 0) sim_item.setTextAlignment(Qt.AlignCenter) if not self.review_mode: - sim_item.setData(Qt.UserRole, i) # Guardamos el índice original en la lista duplicates + sim_item.setData(Qt.UserRole, i) # Columna 1: Nombres de ficheros names_item = QTableWidgetItem(f"{name1} ↔ {name2}") @@ -375,7 +391,8 @@ class DuplicateManagerDialog(QDialog): if row < 0 or row >= self.table_widget.rowCount(): return - # Obtenemos el índice real de la lista duplicates guardado en el UserRole del item + # Obtenemos el índice real de la lista duplicates guardado en el UserRole del + # item item = self.table_widget.item(row, 0) if not item: return @@ -387,12 +404,14 @@ class DuplicateManagerDialog(QDialog): similarity_color = "#f39c12" # Default (amber) if dup.similarity is not None: if dup.similarity == 100: - similarity_color = "#2ecc71" # Green + similarity_color = "#2ecc71" # Green elif dup.similarity < 80: - similarity_color = "#e74c3c" # Red + similarity_color = "#e74c3c" # Red self.similarity_lbl.setText(f"{dup.similarity}% Similarity") - self.similarity_lbl.setStyleSheet(f"font-weight: bold; color: {similarity_color}; font-size: 12px; margin-top: 5px;") + self.similarity_lbl.setStyleSheet( + f"font-weight: bold; color: {similarity_color}; " + "font-size: 12px; margin-top: 5px;") self.similarity_lbl.show() else: self.similarity_lbl.hide() @@ -417,13 +436,26 @@ class DuplicateManagerDialog(QDialog): mtime1 = os.path.getmtime(path_left) if os.path.exists(path_left) else 0 mtime2 = os.path.getmtime(path_right) if os.path.exists(path_right) else 0 - # La imagen más reciente (mtime más alto) va a la izquierda + # Recent image to the left, older to the right if mtime1 >= mtime2: - self._set_pane_data(self.left_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left) - self._set_pane_data(self.right_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right) + dis_l = self._set_pane_data( + self.left_pane_widget, path_left, filename_color, + dir_color, filename_left, dir_left) + dis_r = self._set_pane_data( + self.right_pane_widget, path_right, filename_color, + dir_color, filename_right, dir_right) else: - self._set_pane_data(self.left_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right) - self._set_pane_data(self.right_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left) + dis_l = self._set_pane_data( + self.left_pane_widget, path_right, filename_color, + dir_color, filename_right, dir_right) + dis_r = self._set_pane_data( + self.right_pane_widget, path_left, filename_color, + dir_color, filename_left, dir_left) + + can_link = not (dis_l or dis_r) + self.panes_linked = self._user_link_preference and can_link + self.btn_link_panes.setEnabled(can_link) + self.btn_link_panes.setChecked(self.panes_linked) # Compare resolutions and highlight the best one p_l = self.left_pane.controller.pixmap_original @@ -432,7 +464,7 @@ class DuplicateManagerDialog(QDialog): res_l = p_l.width() * p_l.height() res_r = p_r.width() * p_r.height() - winner = 0 # 0: none, 1: left, 2: right + winner = 0 # 0: none, 1: left, 2: right if res_l > res_r: winner = 1 elif res_r > res_l: @@ -444,26 +476,40 @@ class DuplicateManagerDialog(QDialog): path_r = self.right_pane.controller.get_current_path() size_l = os.path.getsize(path_l) size_r = os.path.getsize(path_r) - if size_l > size_r: winner = 1 - elif size_r > size_l: winner = 2 - except (OSError, AttributeError): pass + if size_l > size_r: + winner = 1 + elif size_r > size_l: + winner = 2 + except (OSError, AttributeError): + pass if winner == 1: - self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;") - self.left_pane_widget.info_lbl.setText("✓ " + self.left_pane_widget.info_lbl.text()) - self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + self.left_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #2ecc71;") + self.left_pane_widget.info_lbl.setText( + "✓ " + self.left_pane_widget.info_lbl.text()) + self.right_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #aaa;") elif winner == 2: - self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;") - self.right_pane_widget.info_lbl.setText("✓ " + self.right_pane_widget.info_lbl.text()) - self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + self.right_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #2ecc71;") + self.right_pane_widget.info_lbl.setText( + "✓ " + self.right_pane_widget.info_lbl.text()) + self.left_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #aaa;") else: - self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") - self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + self.left_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #aaa;") + self.right_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #aaa;") else: - self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") - self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") + self.left_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #aaa;") + self.right_pane_widget.info_lbl.setStyleSheet( + "font-weight: bold; color: #aaa;") - def _set_pane_data(self, pane_widget, path, filename_color, dir_color, filename_text, dir_text): + def _set_pane_data(self, pane_widget, path, filename_color, dir_color, + filename_text, dir_text) -> bool: pane = pane_widget.pane info_lbl = pane_widget.info_lbl filename_lbl = pane_widget.filename_lbl @@ -471,16 +517,27 @@ class DuplicateManagerDialog(QDialog): if not os.path.exists(path): info_lbl.setText("FILE NOT FOUND") - pane.controller.update_list([], 0) # Clear pane + pane.controller.update_list([], 0) # Clear pane pane.load_and_fit_image() filename_lbl.setText("N/A") dir_lbl.setText("N/A") - return + return True # Metadatos size_bytes = os.path.getsize(path) size_str = self._format_size(size_bytes) + # Detección de imágenes animadas o resoluciones inválidas + reader = QImageReader(path) + is_animated = reader.supportsAnimation() and reader.imageCount() > 1 + is_invalid = (pane.controller.pixmap_original.isNull() or + not pane.controller.pixmap_original.size().isValid()) + disable_linking = is_animated or is_invalid + + self.panes_linked = self._user_link_preference and disable_linking + self.btn_link_panes.setEnabled(disable_linking) + self.btn_link_panes.setChecked(self.panes_linked) + # Load image into pane's controller pane.controller.update_list([path], 0) pane.load_and_fit_image() @@ -495,9 +552,11 @@ class DuplicateManagerDialog(QDialog): info_lbl.setText(f"{size_str} - N/A") filename_lbl.setText(filename_text) - filename_lbl.setStyleSheet(f"font-size: 11px; font-weight: bold; color: {filename_color};") + filename_lbl.setStyleSheet( + f"font-size: 11px; font-weight: bold; color: {filename_color};") dir_lbl.setText(dir_text) dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};") + return disable_linking def _show_pane_context_menu(self, pos): pane = self.sender() @@ -508,7 +567,8 @@ class DuplicateManagerDialog(QDialog): menu = QMenu(self) # Open with... - open_menu = menu.addMenu(QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN) + open_menu = menu.addMenu( + QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN) self.main_win.populate_open_with_submenu(open_menu, path) # Open location @@ -521,28 +581,39 @@ class DuplicateManagerDialog(QDialog): menu.addSeparator() # Clipboard - clip_menu = menu.addMenu(QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD) + clip_menu = menu.addMenu( + QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD) - action_copy_image = clip_menu.addAction(QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE) - action_copy_image.triggered.connect(lambda: QApplication.clipboard().setImage(QImage(path))) + action_copy_image = clip_menu.addAction( + QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE) + action_copy_image.triggered.connect( + lambda: QApplication.clipboard().setImage(QImage(path))) - action_copy_path = clip_menu.addAction(QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH) - action_copy_path.triggered.connect(lambda: QApplication.clipboard().setText(path)) + action_copy_path = clip_menu.addAction( + QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH) + action_copy_path.triggered.connect( + lambda: QApplication.clipboard().setText(path)) menu.addSeparator() # Trash / Delete - action_trash = menu.addAction(QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH) - action_trash.triggered.connect(lambda: self._handle_action(delete_path=path, permanent=False)) + action_trash = menu.addAction( + QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH) + action_trash.triggered.connect( + lambda: self._handle_action(delete_path=path, permanent=False)) - action_delete = menu.addAction(QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE) - action_delete.triggered.connect(lambda: self._handle_permanent_delete(path)) + action_delete = menu.addAction( + QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE) + action_delete.triggered.connect( + lambda: self._handle_permanent_delete(path)) menu.addSeparator() # Properties - action_props = menu.addAction(QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES) - action_props.triggered.connect(lambda: self._show_properties(path, pane)) + action_props = menu.addAction( + QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES) + action_props.triggered.connect( + lambda: self._show_properties(path, pane)) menu.exec(pane.mapToGlobal(pos)) @@ -550,7 +621,8 @@ class DuplicateManagerDialog(QDialog): confirm = QMessageBox(self) confirm.setIcon(QMessageBox.Warning) confirm.setText(UITexts.CONFIRM_DELETE_TEXT) - confirm.setInformativeText(UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path))) + confirm.setInformativeText( + UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path))) confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) confirm.setDefaultButton(QMessageBox.No) if confirm.exec() == QMessageBox.Yes: @@ -559,14 +631,16 @@ class DuplicateManagerDialog(QDialog): def _show_properties(self, path, pane): tags = pane.controller._current_tags rating = pane.controller._current_rating - dlg = PropertiesDialog(path, initial_tags=tags, initial_rating=rating, parent=self) + dlg = PropertiesDialog( + path, initial_tags=tags, initial_rating=rating, parent=self) dlg.exec() def _on_pane_activated(self): # When a pane is activated, ensure its zoom/scroll is the reference for linking if self.panes_linked: active_pane = self.sender() # The pane that emitted activated signal - other_pane = self.left_pane if active_pane == self.right_pane else self.right_pane + other_pane = self.left_pane \ + if active_pane == self.right_pane else self.right_pane self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane) # Need to get scroll position from active_pane and apply to other h_bar = active_pane.scroll_area.horizontalScrollBar() @@ -603,17 +677,20 @@ class DuplicateManagerDialog(QDialog): x_pct = h_bar.value() / h_bar.maximum() if h_bar.maximum() > 0 else 0 y_pct = v_bar.value() / v_bar.maximum() if v_bar.maximum() > 0 else 0 - target_pane = self.left_pane if source_pane == self.right_pane else self.right_pane + target_pane = self.left_pane \ + if source_pane == self.right_pane else self.right_pane target_pane.zoom_manager.zoom(absolute_factor=factor) # Re-apply relative scroll after zoom changes bounds - QTimer.singleShot(0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y)) + QTimer.singleShot( + 0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y)) finally: self._is_syncing = False def _format_size(self, size): for unit in ['B', 'KiB', 'MiB', 'GiB']: - if size < 1024: return f"{size:.1f} {unit}" + if size < 1024: + return f"{size:.1f} {unit}" size /= 1024 return f"{size:.1f} TiB" @@ -628,7 +705,8 @@ class DuplicateManagerDialog(QDialog): self._handle_action(delete_path=path_to_delete) def _toggle_link_panes(self): - self.panes_linked = self.btn_link_panes.isChecked() + self._user_link_preference = self.btn_link_panes.isChecked() + self.panes_linked = self._user_link_preference if self.panes_linked: # When linking, synchronize the other pane to the active one # For simplicity, let's always sync right to left if linking is enabled @@ -645,7 +723,8 @@ class DuplicateManagerDialog(QDialog): path = os.path.abspath(path) # 1. Identify pairs to remove and clean up the pending DB - pairs_to_remove = [d for d in self.duplicates if d.path1 == path or d.path2 == path] + pairs_to_remove = [d for d in self.duplicates + if d.path1 == path or d.path2 == path] if not pairs_to_remove: return @@ -699,12 +778,16 @@ class DuplicateManagerDialog(QDialog): def _skip(self): if self.review_mode and self.current_dup_pair: - self.cache.mark_as_exception(self.current_dup_pair.path1, self.current_dup_pair.path2, False) + self.cache.mark_as_exception( + self.current_dup_pair.path1, self.current_dup_pair.path2, False) # Borramos los hashes para que el detector las trate como imágenes nuevas # y fuerce una nueva comparación en el siguiente escaneo. - # Usamos clear_relationships=False para no perder otras posibles coincidencias ya marcadas. - self.cache.remove_hash_for_path(self.current_dup_pair.path1, clear_relationships=False) - self.cache.remove_hash_for_path(self.current_dup_pair.path2, clear_relationships=False) + # Usamos clear_relationships=False para no perder otras posibles + # coincidencias ya marcadas. + self.cache.remove_hash_for_path( + self.current_dup_pair.path1, clear_relationships=False) + self.cache.remove_hash_for_path( + self.current_dup_pair.path2, clear_relationships=False) self._handle_action(skip=False, permanent=False) else: self._handle_action(skip=True) @@ -732,27 +815,35 @@ class DuplicateManagerDialog(QDialog): # Remove all pairs containing this path from the persistent pending DB # because the file will be gone. - pairs_to_unmark = [d for d in self.duplicates if d.path1 == delete_path or d.path2 == delete_path] + pairs_to_unmark = [d for d in self.duplicates + if d.path1 == delete_path or d.path2 == delete_path] for p in pairs_to_unmark: self.cache.mark_as_pending(p.path1, p.path2, False) - self.main_win.delete_file_by_path(delete_path, permanent=permanent) # Use default setting + self.main_win.delete_file_by_path(delete_path, permanent=permanent) if os.path.exists(delete_path): - QMessageBox.warning(self, UITexts.ERROR, UITexts.ERROR_DELETING_FILE.format(delete_path)) + QMessageBox.warning( + self, UITexts.ERROR, + UITexts.ERROR_DELETING_FILE.format(delete_path)) return # Remove all pairs containing this path because it no longer exists - self.duplicates = [d for d in self.duplicates if d.path1 != delete_path and d.path2 != delete_path] + self.duplicates = [d for d in self.duplicates + if d.path1 != delete_path and d.path2 != delete_path] else: # Skip or KeepBoth: - if not skip: # "Keep Both" case - # It's no longer pending, it's an exception (already marked in _keep_both) - self.cache.mark_as_pending(current_pair.path1, current_pair.path2, False) - # Note: if it's "Skip", we do NOT remove it from pending DB, so it stays there for next time. + if not skip: # "Keep Both" case + # It's no longer pending, it's an exception (already marked in + # _keep_both) + self.cache.mark_as_pending( + current_pair.path1, current_pair.path2, False) + # Note: if it's "Skip", we do NOT remove it from pending DB, so it stays + # there for next time. if 0 <= original_index < len(self.duplicates): self.duplicates.pop(original_index) - # Repopulate list widget to ensure all indices are correct and counter is updated + # Repopulate list widget to ensure all indices are correct and counter is + # updated self._populate_list() # Try to restore selection to same position (or last item) diff --git a/imageviewer.py b/imageviewer.py index cc3d11b..13a448c 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -424,7 +424,7 @@ class FaceCanvas(QLabel): self.zoom_indicator_point = None self.zoom_indicator_timer = QTimer(self) self.zoom_indicator_timer.setSingleShot(True) - self.zoom_indicator_timer.setInterval(500) # Show for 500ms + self.zoom_indicator_timer.setInterval(500) # Show for 500ms self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator) self.crop_rect = QRect() self.crop_handle = None @@ -636,13 +636,15 @@ class FaceCanvas(QLabel): # Draw zoom indicator if self.zoom_indicator_point: - painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair + painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair painter.drawLine(self.zoom_indicator_point.x() - 10, self.zoom_indicator_point.y(), self.zoom_indicator_point.x() + 10, self.zoom_indicator_point.y()) - painter.drawLine(self.zoom_indicator_point.x(), self.zoom_indicator_point.y() - 10, - self.zoom_indicator_point.x(), self.zoom_indicator_point.y() + 10) + painter.drawLine(self.zoom_indicator_point.x(), + self.zoom_indicator_point.y() - 10, + self.zoom_indicator_point.x(), + self.zoom_indicator_point.y() + 10) def _hit_test(self, pos): """Determines if the mouse is over a name, handle, or body.""" @@ -1145,7 +1147,8 @@ class ZoomManager(QObject): def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None): """Applies zoom to the image, centering on focus_point if provided.""" - if not self.viewer.controller or self.viewer.controller.pixmap_original.isNull(): + if not self.viewer.controller or \ + self.viewer.controller.pixmap_original.isNull(): return c_point = None @@ -1155,9 +1158,10 @@ class ZoomManager(QObject): self.viewer.update_view(resize_win=True) if self.viewer.canvas: c_point = self.viewer.canvas.rect().center() - elif absolute_factor is not None: # New: set absolute zoom factor + elif absolute_factor is not None: # New: set absolute zoom factor self.viewer.controller.zoom_factor = absolute_factor - self.viewer.update_view(resize_win=False) # Don't resize window for sync zoom + # Don't resize window for sync zoom + self.viewer.update_view(resize_win=False) if focus_point is not None and self.viewer.canvas: scroll_area = self.viewer.scroll_area viewport = scroll_area.viewport() @@ -1171,7 +1175,8 @@ class ZoomManager(QObject): if focus_point is None: v_point = viewport.rect().center() else: - # focus_point es relativo al widget self.viewer (ImageViewer o ImagePane) + # focus_point es relativo al widget self.viewer (ImageViewer o + # ImagePane) v_point = viewport.mapFrom(self.viewer, focus_point) # 2. Mapear el punto de enfoque a coordenadas del canvas antes del zoom @@ -1181,7 +1186,8 @@ class ZoomManager(QObject): # Aplicar la actualización (esto redimensiona el canvas) self.viewer.update_view(resize_win=(not self.viewer.isFullScreen())) - # 3. Ajustar las barras de desplazamiento para mantener el píxel bajo el cursor + # 3. Ajustar las barras de desplazamiento para mantener el píxel bajo el + # cursor scroll_area.horizontalScrollBar().setValue( int(c_point.x() * factor - v_point.x())) scroll_area.verticalScrollBar().setValue( @@ -1721,7 +1727,9 @@ class ImageViewer(QWidget): for pane in self.panes: if pane != self.active_pane: - QTimer.singleShot(0, lambda p=pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y)) + QTimer.singleShot( + 0, lambda p=pane, x=x_pct, + y=y_pct: p.set_scroll_relative(x, y)) def update_grid_layout(self): # Clear layout @@ -1761,7 +1769,8 @@ class ImageViewer(QWidget): new_idx = (start_idx + i + 1) % len(img_list) pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load if self.panes_linked and self.active_pane: - pane.controller.zoom_factor = self.active_pane.controller.zoom_factor + pane.controller.zoom_factor = \ + self.active_pane.controller.zoom_factor pane.load_and_fit_image() else: # Remove panes (keep active if possible, else keep first) @@ -1779,7 +1788,7 @@ class ImageViewer(QWidget): # sizing QTimer.singleShot( 0, lambda: self.active_pane.update_view(resize_win=True)) - self.adjustSize() # Ajustar el tamaño de la ventana después de añadir/eliminar paneles + self.adjustSize() def toggle_link_panes(self): """Toggles the synchronized zoom/scroll for comparison mode.""" diff --git a/metadatamanager.py b/metadatamanager.py index f089a1b..4c27265 100644 --- a/metadatamanager.py +++ b/metadatamanager.py @@ -17,11 +17,12 @@ except ImportError: exiv2 = None HAVE_EXIV2 = False -_app_modified_callback = None from utils import preserve_mtime from constants import RATING_XATTR_NAME, XATTR_NAME +_app_modified_callback = None + MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating']) EMPTY_METADATA = MetadataResult([], 0) @@ -30,11 +31,13 @@ def set_app_modified_callback(callback): global _app_modified_callback _app_modified_callback = callback + def mark_app_modified(path): """Triggers the application-modified callback for a path.""" if _app_modified_callback: _app_modified_callback(path) + def notify_baloo(path): """ Notifies the Baloo file indexer about a file change using DBus. diff --git a/xmpmanager.py b/xmpmanager.py index 68c1c9e..2cbcc5f 100644 --- a/xmpmanager.py +++ b/xmpmanager.py @@ -15,7 +15,6 @@ Dependencies: - utils.preserve_mtime: A utility to prevent file modification times from changing during metadata writes. """ -from importlib.resources import path import os import re from utils import preserve_mtime