Fixed hang with gifs in duplicates form
This commit is contained in:
@@ -1844,7 +1844,8 @@ class MainWindow(QMainWindow):
|
|||||||
self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
|
self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
|
||||||
return
|
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)
|
self.start_duplicate_detection(force_full=False, custom_paths=paths)
|
||||||
|
|
||||||
def _gather_files_for_duplicates(self):
|
def _gather_files_for_duplicates(self):
|
||||||
@@ -4879,14 +4880,17 @@ class MainWindow(QMainWindow):
|
|||||||
new_size = new_stat.st_size
|
new_size = new_stat.st_size
|
||||||
|
|
||||||
# Find old data from internal list
|
# 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_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:
|
if new_size == old_size and new_mtime != old_mtime:
|
||||||
# Likely metadata-only change (size unchanged, mtime changed)
|
# Likely metadata-only change (size unchanged, mtime changed)
|
||||||
res = load_common_metadata(path)
|
res = load_common_metadata(path)
|
||||||
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating,
|
self._update_internal_data(
|
||||||
|
path, mtime=new_mtime, tags=res.tags, rating=res.rating,
|
||||||
inode=new_stat.st_ino, dev=new_stat.st_dev)
|
inode=new_stat.st_ino, dev=new_stat.st_dev)
|
||||||
self.proxy_model.add_to_cache(path, res.tags)
|
self.proxy_model.add_to_cache(path, res.tags)
|
||||||
self.thumbnail_view.viewport().update() # Force repaint
|
self.thumbnail_view.viewport().update() # Force repaint
|
||||||
@@ -4895,7 +4899,8 @@ class MainWindow(QMainWindow):
|
|||||||
# Content or size changed, invalidate thumbnail and rebuild view
|
# Content or size changed, invalidate thumbnail and rebuild view
|
||||||
self.cache.invalidate_path(path)
|
self.cache.invalidate_path(path)
|
||||||
res = load_common_metadata(path) # Re-read metadata as well
|
res = load_common_metadata(path) # Re-read metadata as well
|
||||||
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating,
|
self._update_internal_data(
|
||||||
|
path, mtime=new_mtime, tags=res.tags, rating=res.rating,
|
||||||
inode=new_stat.st_ino, dev=new_stat.st_dev)
|
inode=new_stat.st_ino, dev=new_stat.st_dev)
|
||||||
self.proxy_model.add_to_cache(path, res.tags)
|
self.proxy_model.add_to_cache(path, res.tags)
|
||||||
self.rebuild_view()
|
self.rebuild_view()
|
||||||
@@ -4912,8 +4917,10 @@ class MainWindow(QMainWindow):
|
|||||||
self._paths_being_modified_by_app.add(parent_path)
|
self._paths_being_modified_by_app.add(parent_path)
|
||||||
|
|
||||||
# Schedule removal after a delay to allow all FS events to propagate
|
# 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(
|
||||||
QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(parent_path))
|
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):
|
def on_fs_watcher_status_changed(self, is_monitoring):
|
||||||
"""Updates the UI indicator for the FileSystemWatcher."""
|
"""Updates the UI indicator for the FileSystemWatcher."""
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ from constants import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Result structure for duplicate detection
|
# 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:
|
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):
|
def __init__(self, distance_func):
|
||||||
self.distance_func = distance_func
|
self.distance_func = distance_func
|
||||||
self.tree = None
|
self.tree = None
|
||||||
@@ -210,7 +213,8 @@ class DuplicateCache(QObject):
|
|||||||
return None, 0, None
|
return None, 0, None
|
||||||
|
|
||||||
with QWriteLocker(self._hash_cache_lock):
|
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 hash_str, mtime, path_str
|
||||||
return None, 0, None
|
return None, 0, None
|
||||||
|
|
||||||
@@ -225,7 +229,8 @@ class DuplicateCache(QObject):
|
|||||||
return hash_value
|
return hash_value
|
||||||
return None
|
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:
|
if dev_id is None or inode_key_bytes is None:
|
||||||
dev_id, inode_key_bytes = self._get_inode_info(path)
|
dev_id, inode_key_bytes = self._get_inode_info(path)
|
||||||
if not inode_key_bytes or not self._lmdb_env:
|
if not inode_key_bytes or not self._lmdb_env:
|
||||||
@@ -264,8 +269,10 @@ class DuplicateCache(QObject):
|
|||||||
|
|
||||||
# Also remove any exceptions involving this path
|
# Also remove any exceptions involving this path
|
||||||
if clear_relationships:
|
if clear_relationships:
|
||||||
self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._exceptions_db)
|
self._remove_pair_entries_for_path(
|
||||||
self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._pending_db)
|
dev_id, inode_key_bytes, self._exceptions_db)
|
||||||
|
self._remove_pair_entries_for_path(
|
||||||
|
dev_id, inode_key_bytes, self._pending_db)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2):
|
def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2):
|
||||||
@@ -280,7 +287,9 @@ class DuplicateCache(QObject):
|
|||||||
return None
|
return None
|
||||||
return self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2)
|
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:
|
if not self._lmdb_env:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -323,8 +332,10 @@ class DuplicateCache(QObject):
|
|||||||
with self._lmdb_env.begin(write=False) as txn:
|
with self._lmdb_env.begin(write=False) as txn:
|
||||||
return txn.get(exception_key, db=self._exceptions_db) is not None
|
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):
|
def _remove_pair_entries_for_path(self,
|
||||||
"""Removes all entries involving a specific (dev, inode) pair from a pair-based DB."""
|
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:
|
if not self._lmdb_env:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -336,8 +347,10 @@ class DuplicateCache(QObject):
|
|||||||
for key_bytes, _ in cursor:
|
for key_bytes, _ in cursor:
|
||||||
key_str = key_bytes.decode('utf-8')
|
key_str = key_bytes.decode('utf-8')
|
||||||
parts = key_str.split('-')
|
parts = key_str.split('-')
|
||||||
if len(parts) < 4: continue
|
if len(parts) < 4:
|
||||||
dev1, inode1_hex, dev2, inode2_hex = int(parts[0]), parts[1], int(parts[2]), parts[3]
|
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 \
|
if (dev1 == target_dev and inode1_hex == target_inode_hex) or \
|
||||||
(dev2 == target_dev and inode2_hex == target_inode_hex):
|
(dev2 == target_dev and inode2_hex == target_inode_hex):
|
||||||
keys_to_delete.append(key_bytes)
|
keys_to_delete.append(key_bytes)
|
||||||
@@ -351,7 +364,8 @@ class DuplicateCache(QObject):
|
|||||||
with self._lmdb_env.begin(write=True) as t:
|
with self._lmdb_env.begin(write=True) as t:
|
||||||
do_remove(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."""
|
"""Marks a pair as pending review."""
|
||||||
if not self._lmdb_env or self._pending_db is None:
|
if not self._lmdb_env or self._pending_db is None:
|
||||||
return False
|
return False
|
||||||
@@ -392,7 +406,8 @@ class DuplicateCache(QObject):
|
|||||||
sim = int(parts[2]) if len(parts) > 2 and parts[2] else None
|
sim = int(parts[2]) if len(parts) > 2 and parts[2] else None
|
||||||
ts = int(parts[3]) if len(parts) > 3 else 0
|
ts = int(parts[3]) if len(parts) > 3 else 0
|
||||||
if os.path.exists(p1) and os.path.exists(p2):
|
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:
|
else:
|
||||||
keys_to_delete.append(key)
|
keys_to_delete.append(key)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -404,7 +419,8 @@ class DuplicateCache(QObject):
|
|||||||
with self._lmdb_env.begin(write=True) as txn:
|
with self._lmdb_env.begin(write=True) as txn:
|
||||||
for k in keys_to_delete:
|
for k in keys_to_delete:
|
||||||
txn.delete(k, db=self._pending_db)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up pending duplicates from DB: {e}")
|
logger.error(f"Error cleaning up pending duplicates from DB: {e}")
|
||||||
|
|
||||||
@@ -436,23 +452,28 @@ class DuplicateCache(QObject):
|
|||||||
if len(parts) > 3:
|
if len(parts) > 3:
|
||||||
ts = int(parts[3])
|
ts = int(parts[3])
|
||||||
else:
|
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:
|
if not p1 or not p2:
|
||||||
# Legacy format fallback: lookup paths in hash db
|
# Legacy format fallback: lookup paths in hash db
|
||||||
key_str = key_bytes.decode('utf-8')
|
key_str = key_bytes.decode('utf-8')
|
||||||
kp = key_str.split('-')
|
kp = key_str.split('-')
|
||||||
if len(kp) == 4:
|
if len(kp) == 4:
|
||||||
k1, k2 = f"{kp[0]}-{kp[1]}".encode(), f"{kp[2]}-{kp[3]}".encode()
|
k1, k2 = f"{kp[0]}-{kp[1]}".encode(),
|
||||||
v1, v2 = txn.get(k1, db=self._hash_db), txn.get(k2, db=self._hash_db)
|
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:
|
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]
|
p1 = v1.decode('utf-8').split('|')[2]
|
||||||
p2 = v2.decode('utf-8').split('|')[2]
|
p2 = v2.decode('utf-8').split('|')[2]
|
||||||
|
|
||||||
if p1 and p2:
|
if p1 and p2:
|
||||||
if os.path.exists(p1) and os.path.exists(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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
return results
|
return results
|
||||||
@@ -484,11 +505,13 @@ class DuplicateCache(QObject):
|
|||||||
with self._lmdb_env.begin(write=True) as txn:
|
with self._lmdb_env.begin(write=True) as txn:
|
||||||
for k in keys_to_delete:
|
for k in keys_to_delete:
|
||||||
txn.delete(k, db=self._hash_db)
|
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)
|
return len(keys_to_delete)
|
||||||
|
|
||||||
def get_all_hashes_with_paths(self):
|
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)]
|
# hash_value -> [(path, dev_id, inode_key_bytes)]
|
||||||
all_hashes = collections.defaultdict(list)
|
all_hashes = collections.defaultdict(list)
|
||||||
if not self._lmdb_env:
|
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:
|
if not old_inode_key_bytes or not new_inode_key_bytes or not self._lmdb_env:
|
||||||
return False
|
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.
|
# This happens if the file is renamed within the same filesystem.
|
||||||
if (old_dev, old_inode_key_bytes) == (new_dev, new_inode_key_bytes):
|
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)
|
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.
|
# 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)
|
hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes)
|
||||||
if hash_value:
|
if hash_value:
|
||||||
self.remove_hash_for_path(old_path) # This removes the old (dev, inode) entry
|
# This removes the old (dev, inode) entry
|
||||||
self.add_hash_for_path(new_path, hash_value, mtime) # Adds new (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)
|
self._update_pair_paths(old_path, new_path, self._pending_db)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -573,7 +599,9 @@ class DuplicateDetector(QThread):
|
|||||||
duplicates_found = Signal(list) # List of DuplicateResult
|
duplicates_found = Signal(list) # List of DuplicateResult
|
||||||
detection_finished = Signal()
|
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__()
|
super().__init__()
|
||||||
self.paths_to_scan = paths_to_scan
|
self.paths_to_scan = paths_to_scan
|
||||||
self.duplicate_cache = duplicate_cache
|
self.duplicate_cache = duplicate_cache
|
||||||
@@ -590,12 +618,14 @@ class DuplicateDetector(QThread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
total_files = len(self.paths_to_scan)
|
total_files = len(self.paths_to_scan)
|
||||||
found_duplicates = []
|
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
|
last_update_time = 0
|
||||||
|
|
||||||
pool = self.pool_manager.get_pool()
|
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:
|
if not self.force_full:
|
||||||
pending = self.duplicate_cache.get_all_pending_duplicates()
|
pending = self.duplicate_cache.get_all_pending_duplicates()
|
||||||
for p in pending:
|
for p in pending:
|
||||||
@@ -606,7 +636,10 @@ class DuplicateDetector(QThread):
|
|||||||
|
|
||||||
# Convert similarity threshold (percentage) to Hamming distance
|
# Convert similarity threshold (percentage) to Hamming distance
|
||||||
distance_threshold = int(MAX_DHASH_DISTANCE * (100 - self.threshold) / 100)
|
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)
|
# 2. Phase 1: Hash Collection (Parallelized)
|
||||||
path_to_hash = {}
|
path_to_hash = {}
|
||||||
@@ -645,7 +678,8 @@ class DuplicateDetector(QThread):
|
|||||||
break
|
break
|
||||||
current_batch = paths_to_hash_parallel[i : i + batch_size]
|
current_batch = paths_to_hash_parallel[i : i + batch_size]
|
||||||
for p_data in current_batch:
|
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)):
|
for _ in range(len(current_batch)):
|
||||||
while not sem.tryAcquire(1, 100):
|
while not sem.tryAcquire(1, 100):
|
||||||
@@ -655,7 +689,9 @@ class DuplicateDetector(QThread):
|
|||||||
break
|
break
|
||||||
processed_hashing += 1
|
processed_hashing += 1
|
||||||
if time.perf_counter() - last_update_time > 0.05:
|
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()
|
last_update_time = time.perf_counter()
|
||||||
|
|
||||||
for p, mtime, dev, inode in paths_to_hash_parallel:
|
for p, mtime, dev, inode in paths_to_hash_parallel:
|
||||||
@@ -670,7 +706,9 @@ class DuplicateDetector(QThread):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Signal phase transition to exactly 50%
|
# 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)
|
# 3. Phase 2: Comparison (Optimized with BK-Tree)
|
||||||
hash_map = collections.defaultdict(list)
|
hash_map = collections.defaultdict(list)
|
||||||
@@ -684,9 +722,12 @@ class DuplicateDetector(QThread):
|
|||||||
if self.force_full or p in dirty_paths:
|
if self.force_full or p in dirty_paths:
|
||||||
dirty_hashes_objs.add(h_obj)
|
dirty_hashes_objs.add(h_obj)
|
||||||
|
|
||||||
# Optimization: Only query the tree for hashes associated with new or modified files.
|
# Optimization: Only query the tree for hashes associated with new or modified
|
||||||
# This finds pairs (Dirty, Clean) and (Dirty, Dirty). (Clean, Clean) were handled in previous runs.
|
# files.
|
||||||
hashes_to_query = list(dirty_hashes_objs) if not self.force_full else list(hash_map.keys())
|
# 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)
|
total_queries = len(hashes_to_query)
|
||||||
|
|
||||||
for i, h1 in enumerate(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:
|
if time.perf_counter() - last_update_time > 0.1:
|
||||||
# Scale Phase 2 progress to the 50%-100% range
|
# 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
|
phase2_progress = int(((i + 1) / total_queries) * total_files) \
|
||||||
self.progress_update.emit(total_files + phase2_progress, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
|
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()
|
last_update_time = time.perf_counter()
|
||||||
|
|
||||||
# Query tree for similar hashes
|
# Query tree for similar hashes
|
||||||
@@ -713,7 +757,8 @@ class DuplicateDetector(QThread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Optimization: Skip pair if BOTH were already verified
|
# 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
|
continue
|
||||||
|
|
||||||
canonical = frozenset((p1, p2))
|
canonical = frozenset((p1, p2))
|
||||||
@@ -726,7 +771,8 @@ class DuplicateDetector(QThread):
|
|||||||
res = DuplicateResult(p1, p2, str(h1), False, sim, ts)
|
res = DuplicateResult(p1, p2, str(h1), False, sim, ts)
|
||||||
found_duplicates.append(res)
|
found_duplicates.append(res)
|
||||||
unique_duplicate_pairs.add(canonical)
|
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.duplicates_found.emit(found_duplicates)
|
||||||
self.detection_finished.emit()
|
self.detection_finished.emit()
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from PySide6.QtWidgets import (
|
|||||||
QSplitter, QWidget, QMessageBox, QApplication, QMenu,
|
QSplitter, QWidget, QMessageBox, QApplication, QMenu,
|
||||||
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView
|
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 PySide6.QtCore import Qt, QTimer, QUrl
|
||||||
from imageviewer import ImagePane
|
from imageviewer import ImagePane
|
||||||
from propertiesdialog import PropertiesDialog
|
|
||||||
from constants import APP_CONFIG, UITexts
|
from constants import APP_CONFIG, UITexts
|
||||||
|
from propertiesdialog import PropertiesDialog
|
||||||
|
|
||||||
|
|
||||||
class DuplicateManagerDialog(QDialog):
|
class DuplicateManagerDialog(QDialog):
|
||||||
@@ -26,6 +26,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.active_pane = None
|
self.active_pane = None
|
||||||
self.current_dup_pair = None # Stores the current DuplicateResult object
|
self.current_dup_pair = None # Stores the current DuplicateResult object
|
||||||
self.panes_linked = True # Default to linked
|
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._is_syncing = False # Guard to prevent recursion during synchronization
|
||||||
|
|
||||||
self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE)
|
self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE)
|
||||||
@@ -37,7 +38,8 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
|
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
|
||||||
self.main_win.fs_watcher.file_deleted.connect(
|
self.main_win.fs_watcher.file_deleted.connect(
|
||||||
self._on_file_deleted_externally)
|
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:
|
if self.duplicates:
|
||||||
self.table_widget.setCurrentCell(0, 0)
|
self.table_widget.setCurrentCell(0, 0)
|
||||||
@@ -59,7 +61,8 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
|
|
||||||
self.table_widget = QTableWidget()
|
self.table_widget = QTableWidget()
|
||||||
if self.review_mode:
|
if self.review_mode:
|
||||||
self.table_widget.setColumnCount(3)
|
columns = 3
|
||||||
|
self.table_widget.setColumnCount(columns)
|
||||||
self.table_widget.setHorizontalHeaderLabels(
|
self.table_widget.setHorizontalHeaderLabels(
|
||||||
[UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN])
|
[UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN])
|
||||||
self.table_widget.horizontalHeader().setSectionResizeMode(
|
self.table_widget.horizontalHeader().setSectionResizeMode(
|
||||||
@@ -69,9 +72,10 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.table_widget.horizontalHeader().setSectionResizeMode(
|
self.table_widget.horizontalHeader().setSectionResizeMode(
|
||||||
2, QHeaderView.Stretch)
|
2, QHeaderView.Stretch)
|
||||||
else:
|
else:
|
||||||
self.table_widget.setColumnCount(2)
|
columns = 2
|
||||||
|
self.table_widget.setColumnCount(columns)
|
||||||
self.table_widget.setHorizontalHeaderLabels(
|
self.table_widget.setHorizontalHeaderLabels(
|
||||||
["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica
|
["%", UITexts.CONTEXT_MENU_OPEN])
|
||||||
self.table_widget.horizontalHeader().setSectionResizeMode(
|
self.table_widget.horizontalHeader().setSectionResizeMode(
|
||||||
0, QHeaderView.ResizeToContents)
|
0, QHeaderView.ResizeToContents)
|
||||||
self.table_widget.horizontalHeader().setSectionResizeMode(
|
self.table_widget.horizontalHeader().setSectionResizeMode(
|
||||||
@@ -103,18 +107,22 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
button_widget = QWidget()
|
button_widget = QWidget()
|
||||||
btn_layout = QHBoxLayout(button_widget)
|
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_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_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.setCheckable(True)
|
||||||
self.btn_link_panes.setChecked(self.panes_linked)
|
self.btn_link_panes.setChecked(self.panes_linked)
|
||||||
self.btn_link_panes.clicked.connect(self._toggle_link_panes)
|
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_keep_both.clicked.connect(self._keep_both)
|
||||||
|
|
||||||
self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP)
|
self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP)
|
||||||
@@ -135,7 +143,9 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.similarity_lbl = QLabel()
|
self.similarity_lbl = QLabel()
|
||||||
self.similarity_lbl.setAlignment(Qt.AlignCenter)
|
self.similarity_lbl.setAlignment(Qt.AlignCenter)
|
||||||
self.similarity_lbl.setMinimumHeight(30)
|
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 = QVBoxLayout()
|
||||||
main_right_layout.addWidget(self.comparison_widget, 1)
|
main_right_layout.addWidget(self.comparison_widget, 1)
|
||||||
@@ -156,8 +166,10 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
"""Disconnects signals and performs cleanup when closing."""
|
"""Disconnects signals and performs cleanup when closing."""
|
||||||
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
|
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
|
||||||
try:
|
try:
|
||||||
self.main_win.fs_watcher.file_deleted.disconnect(self._on_file_deleted_externally)
|
self.main_win.fs_watcher.file_deleted.disconnect(
|
||||||
self.main_win.fs_watcher.file_moved.disconnect(self._on_file_moved_externally)
|
self._on_file_deleted_externally)
|
||||||
|
self.main_win.fs_watcher.file_moved.disconnect(
|
||||||
|
self._on_file_moved_externally)
|
||||||
except (RuntimeError, TypeError):
|
except (RuntimeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -340,21 +352,25 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
if self.review_mode:
|
if self.review_mode:
|
||||||
# Column 0: Ignored Date
|
# Column 0: Ignored Date
|
||||||
ts = dup.timestamp if hasattr(dup, 'timestamp') and dup.timestamp else 0
|
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 = 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)
|
date_item.setTextAlignment(Qt.AlignCenter)
|
||||||
self.table_widget.setItem(row, 0, date_item)
|
self.table_widget.setItem(row, 0, date_item)
|
||||||
col_offset = 1
|
col_offset = 1
|
||||||
else:
|
else:
|
||||||
col_offset = 0
|
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 = 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)
|
sim_item.setTextAlignment(Qt.AlignCenter)
|
||||||
if not self.review_mode:
|
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
|
# Columna 1: Nombres de ficheros
|
||||||
names_item = QTableWidgetItem(f"{name1} ↔ {name2}")
|
names_item = QTableWidgetItem(f"{name1} ↔ {name2}")
|
||||||
@@ -375,7 +391,8 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
if row < 0 or row >= self.table_widget.rowCount():
|
if row < 0 or row >= self.table_widget.rowCount():
|
||||||
return
|
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)
|
item = self.table_widget.item(row, 0)
|
||||||
if not item:
|
if not item:
|
||||||
return
|
return
|
||||||
@@ -392,7 +409,9 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
similarity_color = "#e74c3c" # Red
|
similarity_color = "#e74c3c" # Red
|
||||||
|
|
||||||
self.similarity_lbl.setText(f"{dup.similarity}% Similarity")
|
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()
|
self.similarity_lbl.show()
|
||||||
else:
|
else:
|
||||||
self.similarity_lbl.hide()
|
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
|
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
|
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:
|
if mtime1 >= mtime2:
|
||||||
self._set_pane_data(self.left_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left)
|
dis_l = self._set_pane_data(
|
||||||
self._set_pane_data(self.right_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right)
|
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:
|
else:
|
||||||
self._set_pane_data(self.left_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right)
|
dis_l = self._set_pane_data(
|
||||||
self._set_pane_data(self.right_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left)
|
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
|
# Compare resolutions and highlight the best one
|
||||||
p_l = self.left_pane.controller.pixmap_original
|
p_l = self.left_pane.controller.pixmap_original
|
||||||
@@ -444,26 +476,40 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
path_r = self.right_pane.controller.get_current_path()
|
path_r = self.right_pane.controller.get_current_path()
|
||||||
size_l = os.path.getsize(path_l)
|
size_l = os.path.getsize(path_l)
|
||||||
size_r = os.path.getsize(path_r)
|
size_r = os.path.getsize(path_r)
|
||||||
if size_l > size_r: winner = 1
|
if size_l > size_r:
|
||||||
elif size_r > size_l: winner = 2
|
winner = 1
|
||||||
except (OSError, AttributeError): pass
|
elif size_r > size_l:
|
||||||
|
winner = 2
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
if winner == 1:
|
if winner == 1:
|
||||||
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;")
|
self.left_pane_widget.info_lbl.setStyleSheet(
|
||||||
self.left_pane_widget.info_lbl.setText("✓ " + self.left_pane_widget.info_lbl.text())
|
"font-weight: bold; color: #2ecc71;")
|
||||||
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
|
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:
|
elif winner == 2:
|
||||||
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;")
|
self.right_pane_widget.info_lbl.setStyleSheet(
|
||||||
self.right_pane_widget.info_lbl.setText("✓ " + self.right_pane_widget.info_lbl.text())
|
"font-weight: bold; color: #2ecc71;")
|
||||||
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
|
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:
|
else:
|
||||||
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
|
self.left_pane_widget.info_lbl.setStyleSheet(
|
||||||
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
|
"font-weight: bold; color: #aaa;")
|
||||||
|
self.right_pane_widget.info_lbl.setStyleSheet(
|
||||||
|
"font-weight: bold; color: #aaa;")
|
||||||
else:
|
else:
|
||||||
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
|
self.left_pane_widget.info_lbl.setStyleSheet(
|
||||||
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
|
"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
|
pane = pane_widget.pane
|
||||||
info_lbl = pane_widget.info_lbl
|
info_lbl = pane_widget.info_lbl
|
||||||
filename_lbl = pane_widget.filename_lbl
|
filename_lbl = pane_widget.filename_lbl
|
||||||
@@ -475,12 +521,23 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
pane.load_and_fit_image()
|
pane.load_and_fit_image()
|
||||||
filename_lbl.setText("N/A")
|
filename_lbl.setText("N/A")
|
||||||
dir_lbl.setText("N/A")
|
dir_lbl.setText("N/A")
|
||||||
return
|
return True
|
||||||
|
|
||||||
# Metadatos
|
# Metadatos
|
||||||
size_bytes = os.path.getsize(path)
|
size_bytes = os.path.getsize(path)
|
||||||
size_str = self._format_size(size_bytes)
|
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
|
# Load image into pane's controller
|
||||||
pane.controller.update_list([path], 0)
|
pane.controller.update_list([path], 0)
|
||||||
pane.load_and_fit_image()
|
pane.load_and_fit_image()
|
||||||
@@ -495,9 +552,11 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
info_lbl.setText(f"{size_str} - N/A")
|
info_lbl.setText(f"{size_str} - N/A")
|
||||||
|
|
||||||
filename_lbl.setText(filename_text)
|
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.setText(dir_text)
|
||||||
dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};")
|
dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};")
|
||||||
|
return disable_linking
|
||||||
|
|
||||||
def _show_pane_context_menu(self, pos):
|
def _show_pane_context_menu(self, pos):
|
||||||
pane = self.sender()
|
pane = self.sender()
|
||||||
@@ -508,7 +567,8 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
|
||||||
# Open with...
|
# 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)
|
self.main_win.populate_open_with_submenu(open_menu, path)
|
||||||
|
|
||||||
# Open location
|
# Open location
|
||||||
@@ -521,28 +581,39 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
# Clipboard
|
# 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 = clip_menu.addAction(
|
||||||
action_copy_image.triggered.connect(lambda: QApplication.clipboard().setImage(QImage(path)))
|
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 = clip_menu.addAction(
|
||||||
action_copy_path.triggered.connect(lambda: QApplication.clipboard().setText(path))
|
QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH)
|
||||||
|
action_copy_path.triggered.connect(
|
||||||
|
lambda: QApplication.clipboard().setText(path))
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
# Trash / Delete
|
# Trash / Delete
|
||||||
action_trash = menu.addAction(QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH)
|
action_trash = menu.addAction(
|
||||||
action_trash.triggered.connect(lambda: self._handle_action(delete_path=path, permanent=False))
|
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 = menu.addAction(
|
||||||
action_delete.triggered.connect(lambda: self._handle_permanent_delete(path))
|
QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE)
|
||||||
|
action_delete.triggered.connect(
|
||||||
|
lambda: self._handle_permanent_delete(path))
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
# Properties
|
# Properties
|
||||||
action_props = menu.addAction(QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES)
|
action_props = menu.addAction(
|
||||||
action_props.triggered.connect(lambda: self._show_properties(path, pane))
|
QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES)
|
||||||
|
action_props.triggered.connect(
|
||||||
|
lambda: self._show_properties(path, pane))
|
||||||
|
|
||||||
menu.exec(pane.mapToGlobal(pos))
|
menu.exec(pane.mapToGlobal(pos))
|
||||||
|
|
||||||
@@ -550,7 +621,8 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
confirm = QMessageBox(self)
|
confirm = QMessageBox(self)
|
||||||
confirm.setIcon(QMessageBox.Warning)
|
confirm.setIcon(QMessageBox.Warning)
|
||||||
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
|
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.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
||||||
confirm.setDefaultButton(QMessageBox.No)
|
confirm.setDefaultButton(QMessageBox.No)
|
||||||
if confirm.exec() == QMessageBox.Yes:
|
if confirm.exec() == QMessageBox.Yes:
|
||||||
@@ -559,14 +631,16 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
def _show_properties(self, path, pane):
|
def _show_properties(self, path, pane):
|
||||||
tags = pane.controller._current_tags
|
tags = pane.controller._current_tags
|
||||||
rating = pane.controller._current_rating
|
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()
|
dlg.exec()
|
||||||
|
|
||||||
def _on_pane_activated(self):
|
def _on_pane_activated(self):
|
||||||
# When a pane is activated, ensure its zoom/scroll is the reference for linking
|
# When a pane is activated, ensure its zoom/scroll is the reference for linking
|
||||||
if self.panes_linked:
|
if self.panes_linked:
|
||||||
active_pane = self.sender() # The pane that emitted activated signal
|
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)
|
self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane)
|
||||||
# Need to get scroll position from active_pane and apply to other
|
# Need to get scroll position from active_pane and apply to other
|
||||||
h_bar = active_pane.scroll_area.horizontalScrollBar()
|
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
|
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
|
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)
|
target_pane.zoom_manager.zoom(absolute_factor=factor)
|
||||||
|
|
||||||
# Re-apply relative scroll after zoom changes bounds
|
# 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:
|
finally:
|
||||||
self._is_syncing = False
|
self._is_syncing = False
|
||||||
|
|
||||||
def _format_size(self, size):
|
def _format_size(self, size):
|
||||||
for unit in ['B', 'KiB', 'MiB', 'GiB']:
|
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
|
size /= 1024
|
||||||
return f"{size:.1f} TiB"
|
return f"{size:.1f} TiB"
|
||||||
|
|
||||||
@@ -628,7 +705,8 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._handle_action(delete_path=path_to_delete)
|
self._handle_action(delete_path=path_to_delete)
|
||||||
|
|
||||||
def _toggle_link_panes(self):
|
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:
|
if self.panes_linked:
|
||||||
# When linking, synchronize the other pane to the active one
|
# When linking, synchronize the other pane to the active one
|
||||||
# For simplicity, let's always sync right to left if linking is enabled
|
# 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)
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
# 1. Identify pairs to remove and clean up the pending DB
|
# 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:
|
if not pairs_to_remove:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -699,12 +778,16 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
|
|
||||||
def _skip(self):
|
def _skip(self):
|
||||||
if self.review_mode and self.current_dup_pair:
|
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
|
# Borramos los hashes para que el detector las trate como imágenes nuevas
|
||||||
# y fuerce una nueva comparación en el siguiente escaneo.
|
# y fuerce una nueva comparación en el siguiente escaneo.
|
||||||
# Usamos clear_relationships=False para no perder otras posibles coincidencias ya marcadas.
|
# Usamos clear_relationships=False para no perder otras posibles
|
||||||
self.cache.remove_hash_for_path(self.current_dup_pair.path1, clear_relationships=False)
|
# coincidencias ya marcadas.
|
||||||
self.cache.remove_hash_for_path(self.current_dup_pair.path2, clear_relationships=False)
|
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)
|
self._handle_action(skip=False, permanent=False)
|
||||||
else:
|
else:
|
||||||
self._handle_action(skip=True)
|
self._handle_action(skip=True)
|
||||||
@@ -732,27 +815,35 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
|
|
||||||
# Remove all pairs containing this path from the persistent pending DB
|
# Remove all pairs containing this path from the persistent pending DB
|
||||||
# because the file will be gone.
|
# 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:
|
for p in pairs_to_unmark:
|
||||||
self.cache.mark_as_pending(p.path1, p.path2, False)
|
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):
|
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
|
return
|
||||||
|
|
||||||
# Remove all pairs containing this path because it no longer exists
|
# 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:
|
else:
|
||||||
# Skip or KeepBoth:
|
# Skip or KeepBoth:
|
||||||
if not skip: # "Keep Both" case
|
if not skip: # "Keep Both" case
|
||||||
# It's no longer pending, it's an exception (already marked in _keep_both)
|
# It's no longer pending, it's an exception (already marked in
|
||||||
self.cache.mark_as_pending(current_pair.path1, current_pair.path2, False)
|
# _keep_both)
|
||||||
# Note: if it's "Skip", we do NOT remove it from pending DB, so it stays there for next time.
|
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):
|
if 0 <= original_index < len(self.duplicates):
|
||||||
self.duplicates.pop(original_index)
|
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()
|
self._populate_list()
|
||||||
|
|
||||||
# Try to restore selection to same position (or last item)
|
# Try to restore selection to same position (or last item)
|
||||||
|
|||||||
@@ -641,8 +641,10 @@ class FaceCanvas(QLabel):
|
|||||||
self.zoom_indicator_point.y(),
|
self.zoom_indicator_point.y(),
|
||||||
self.zoom_indicator_point.x() + 10,
|
self.zoom_indicator_point.x() + 10,
|
||||||
self.zoom_indicator_point.y())
|
self.zoom_indicator_point.y())
|
||||||
painter.drawLine(self.zoom_indicator_point.x(), self.zoom_indicator_point.y() - 10,
|
painter.drawLine(self.zoom_indicator_point.x(),
|
||||||
self.zoom_indicator_point.x(), self.zoom_indicator_point.y() + 10)
|
self.zoom_indicator_point.y() - 10,
|
||||||
|
self.zoom_indicator_point.x(),
|
||||||
|
self.zoom_indicator_point.y() + 10)
|
||||||
|
|
||||||
def _hit_test(self, pos):
|
def _hit_test(self, pos):
|
||||||
"""Determines if the mouse is over a name, handle, or body."""
|
"""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):
|
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."""
|
"""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
|
return
|
||||||
|
|
||||||
c_point = None
|
c_point = None
|
||||||
@@ -1157,7 +1160,8 @@ class ZoomManager(QObject):
|
|||||||
c_point = self.viewer.canvas.rect().center()
|
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.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:
|
if focus_point is not None and self.viewer.canvas:
|
||||||
scroll_area = self.viewer.scroll_area
|
scroll_area = self.viewer.scroll_area
|
||||||
viewport = scroll_area.viewport()
|
viewport = scroll_area.viewport()
|
||||||
@@ -1171,7 +1175,8 @@ class ZoomManager(QObject):
|
|||||||
if focus_point is None:
|
if focus_point is None:
|
||||||
v_point = viewport.rect().center()
|
v_point = viewport.rect().center()
|
||||||
else:
|
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)
|
v_point = viewport.mapFrom(self.viewer, focus_point)
|
||||||
|
|
||||||
# 2. Mapear el punto de enfoque a coordenadas del canvas antes del zoom
|
# 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)
|
# Aplicar la actualización (esto redimensiona el canvas)
|
||||||
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
|
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(
|
scroll_area.horizontalScrollBar().setValue(
|
||||||
int(c_point.x() * factor - v_point.x()))
|
int(c_point.x() * factor - v_point.x()))
|
||||||
scroll_area.verticalScrollBar().setValue(
|
scroll_area.verticalScrollBar().setValue(
|
||||||
@@ -1721,7 +1727,9 @@ class ImageViewer(QWidget):
|
|||||||
|
|
||||||
for pane in self.panes:
|
for pane in self.panes:
|
||||||
if pane != self.active_pane:
|
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):
|
def update_grid_layout(self):
|
||||||
# Clear layout
|
# Clear layout
|
||||||
@@ -1761,7 +1769,8 @@ class ImageViewer(QWidget):
|
|||||||
new_idx = (start_idx + i + 1) % len(img_list)
|
new_idx = (start_idx + i + 1) % len(img_list)
|
||||||
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load
|
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load
|
||||||
if self.panes_linked and self.active_pane:
|
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()
|
pane.load_and_fit_image()
|
||||||
else:
|
else:
|
||||||
# Remove panes (keep active if possible, else keep first)
|
# Remove panes (keep active if possible, else keep first)
|
||||||
@@ -1779,7 +1788,7 @@ class ImageViewer(QWidget):
|
|||||||
# sizing
|
# sizing
|
||||||
QTimer.singleShot(
|
QTimer.singleShot(
|
||||||
0, lambda: self.active_pane.update_view(resize_win=True))
|
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):
|
def toggle_link_panes(self):
|
||||||
"""Toggles the synchronized zoom/scroll for comparison mode."""
|
"""Toggles the synchronized zoom/scroll for comparison mode."""
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ except ImportError:
|
|||||||
exiv2 = None
|
exiv2 = None
|
||||||
HAVE_EXIV2 = False
|
HAVE_EXIV2 = False
|
||||||
|
|
||||||
_app_modified_callback = None
|
|
||||||
|
|
||||||
from utils import preserve_mtime
|
from utils import preserve_mtime
|
||||||
from constants import RATING_XATTR_NAME, XATTR_NAME
|
from constants import RATING_XATTR_NAME, XATTR_NAME
|
||||||
|
|
||||||
|
_app_modified_callback = None
|
||||||
|
|
||||||
MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
|
MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
|
||||||
EMPTY_METADATA = MetadataResult([], 0)
|
EMPTY_METADATA = MetadataResult([], 0)
|
||||||
|
|
||||||
@@ -30,11 +31,13 @@ def set_app_modified_callback(callback):
|
|||||||
global _app_modified_callback
|
global _app_modified_callback
|
||||||
_app_modified_callback = callback
|
_app_modified_callback = callback
|
||||||
|
|
||||||
|
|
||||||
def mark_app_modified(path):
|
def mark_app_modified(path):
|
||||||
"""Triggers the application-modified callback for a path."""
|
"""Triggers the application-modified callback for a path."""
|
||||||
if _app_modified_callback:
|
if _app_modified_callback:
|
||||||
_app_modified_callback(path)
|
_app_modified_callback(path)
|
||||||
|
|
||||||
|
|
||||||
def notify_baloo(path):
|
def notify_baloo(path):
|
||||||
"""
|
"""
|
||||||
Notifies the Baloo file indexer about a file change using DBus.
|
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
|
- utils.preserve_mtime: A utility to prevent file modification times from
|
||||||
changing during metadata writes.
|
changing during metadata writes.
|
||||||
"""
|
"""
|
||||||
from importlib.resources import path
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from utils import preserve_mtime
|
from utils import preserve_mtime
|
||||||
|
|||||||
Reference in New Issue
Block a user