Fixed hang with gifs in duplicates form
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user