Compare commits

...

21 Commits

Author SHA1 Message Date
Ignacio Serantes
f3bc2f1e0a v0.9.26 2026-05-07 22:38:49 +02:00
Ignacio Serantes
dffc414182 v0.9.26 2026-05-07 21:44:19 +02:00
Ignacio Serantes
0d3d5ffa11 V0.9.26 2026-05-07 09:58:49 +02:00
Ignacio Serantes
8025bef8d3 v0.9.26 2026-05-03 13:31:48 +02:00
Ignacio Serantes
28b120c9e9 v0.9.25 2026-05-02 19:44:28 +02:00
Ignacio Serantes
a824a01579 v0.9.24 2026-04-19 17:39:01 +02:00
Ignacio Serantes
b5b70326b1 v0.9.23 2026-04-19 12:18:27 +02:00
Ignacio Serantes
9d286112b6 v0.9.22 2026-04-14 20:59:13 +02:00
Ignacio Serantes
b253b6d6e7 v0.9.21 2026-04-12 11:58:32 +02:00
Ignacio Serantes
8ade5fde54 v0.9.21 2026-04-12 11:56:39 +02:00
Ignacio Serantes
1508e629c0 v0.9.20 2026-04-12 08:39:07 +02:00
Ignacio Serantes
07afab6ca3 v0.9.19 2026-04-08 15:47:29 +02:00
Ignacio Serantes
bff99226b0 v0.9.18 2026-04-07 16:22:59 +02:00
Ignacio Serantes
9685c01760 Better status bar messages 2026-04-07 09:17:08 +02:00
Ignacio Serantes
3e374a5871 v0.9.17 2026-04-06 23:55:29 +02:00
Ignacio Serantes
964974431c Fixed hang with gifs in duplicates form 2026-04-06 23:20:27 +02:00
Ignacio Serantes
45c95c1bb1 Fixed thumbnail reload on metadata change 2026-04-06 22:09:13 +02:00
Ignacio Serantes
a717acef87 Several fixes 2026-04-06 20:44:49 +02:00
Ignacio Serantes
ca260d4219 Improve stability issues 2026-04-03 18:41:52 +02:00
Ignacio Serantes
ae00235db8 Fixed core dumped on close 2026-04-01 08:48:06 +02:00
Ignacio Serantes
2fbf04fdb8 Added missing libraries. 2026-03-31 23:40:29 +02:00
16 changed files with 2310 additions and 633 deletions

View File

@@ -14,7 +14,7 @@ Classes:
MainWindow: The main application window containing the thumbnail grid and docks. MainWindow: The main application window containing the thumbnail grid and docks.
""" """
__appname__ = "BagheeraView" __appname__ = "BagheeraView"
__version__ = "0.9.16" __version__ = "0.9.26"
__author__ = "Ignacio Serantes" __author__ = "Ignacio Serantes"
__email__ = "kde@aynoa.net" __email__ = "kde@aynoa.net"
__license__ = "LGPL" __license__ = "LGPL"
@@ -35,8 +35,8 @@ from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QLineEdit, QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QLineEdit,
QTextEdit, QPushButton, QFileDialog, QComboBox, QSlider, QMessageBox, QSizePolicy, QTextEdit, QPushButton, QFileDialog, QComboBox, QSlider, QMessageBox, QSizePolicy,
QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup, QListView, QProgressDialog, QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup,
QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox QListView, QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette, QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette,
@@ -1037,6 +1037,7 @@ class MainWindow(QMainWindow):
self._group_info_cache = {} self._group_info_cache = {}
self._visible_paths_cache = None # Cache for visible image paths self._visible_paths_cache = None # Cache for visible image paths
self._path_to_model_index = {} self._path_to_model_index = {}
self._paths_being_modified_by_app = set() # For ignoring FS events
# Keep references to open viewers to manage their lifecycle # Keep references to open viewers to manage their lifecycle
self.viewers = [] self.viewers = []
@@ -1102,6 +1103,23 @@ class MainWindow(QMainWindow):
# Bottom bar with status and controls # Bottom bar with status and controls
bot = QHBoxLayout() bot = QHBoxLayout()
self.btn_load_all = QPushButton()
self.btn_load_all.setFixedSize(24, 24)
self.btn_load_all.setFocusPolicy(Qt.NoFocus)
self.btn_load_all.clicked.connect(self.load_all_images)
self.update_load_all_button_state()
self.btn_load_all.hide()
bot.addWidget(self.btn_load_all)
self.btn_load_more = QPushButton()
self.btn_load_more.setFixedSize(24, 24)
self.btn_load_more.setFocusPolicy(Qt.NoFocus)
self.btn_load_more.setToolTip(UITexts.LOAD_MORE_TOOLTIP)
self.btn_load_more.clicked.connect(self.load_more_images)
self.btn_load_more.hide()
bot.addWidget(self.btn_load_more)
self.btn_cancel_duplicates = QPushButton() self.btn_cancel_duplicates = QPushButton()
self.btn_cancel_duplicates.setIcon(QIcon.fromTheme("process-stop")) self.btn_cancel_duplicates.setIcon(QIcon.fromTheme("process-stop"))
self.btn_cancel_duplicates.setFixedSize(22, 22) self.btn_cancel_duplicates.setFixedSize(22, 22)
@@ -1132,20 +1150,6 @@ class MainWindow(QMainWindow):
self.hide_progress_timer.setSingleShot(True) self.hide_progress_timer.setSingleShot(True)
self.hide_progress_timer.timeout.connect(self.progress_bar.hide) self.hide_progress_timer.timeout.connect(self.progress_bar.hide)
self.btn_load_more = QPushButton("+")
self.btn_load_more.setFixedSize(24, 24)
self.btn_load_more.setFocusPolicy(Qt.NoFocus)
self.btn_load_more.setToolTip(UITexts.LOAD_MORE_TOOLTIP)
self.btn_load_more.clicked.connect(self.load_more_images)
bot.addWidget(self.btn_load_more)
self.btn_load_all = QPushButton("+a")
self.btn_load_all.setFixedSize(24, 24)
self.btn_load_all.setFocusPolicy(Qt.NoFocus)
self.btn_load_all.clicked.connect(self.load_all_images)
self.update_load_all_button_state()
bot.addWidget(self.btn_load_all)
bot.addStretch() bot.addStretch()
self.filtered_count_lbl = QLabel(UITexts.FILTERED_ZERO) self.filtered_count_lbl = QLabel(UITexts.FILTERED_ZERO)
@@ -1290,12 +1294,6 @@ class MainWindow(QMainWindow):
self.history_tab = HistoryWidget(self) self.history_tab = HistoryWidget(self)
self.tags_tabs.addTab(self.history_tab, UITexts.HISTORY_TAB) self.tags_tabs.addTab(self.history_tab, UITexts.HISTORY_TAB)
# Initialize the shortcut controller
self.shortcut_controller = AppShortcutController(self)
self.favorites_tab.favorites_changed.connect(
self.shortcut_controller.refresh_favorite_shortcuts)
self.main_dock.setWidget(self.tags_tabs) self.main_dock.setWidget(self.tags_tabs)
self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock) self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
@@ -1355,6 +1353,10 @@ class MainWindow(QMainWindow):
self.fs_watcher.monitoring_status_changed.connect( self.fs_watcher.monitoring_status_changed.connect(
self.on_fs_watcher_status_changed) self.on_fs_watcher_status_changed)
# Set up callback for metadata managers
from metadatamanager import set_app_modified_callback
set_app_modified_callback(self._mark_path_as_app_modified)
# Batching for file creation events # Batching for file creation events
self._fs_created_queue = set() self._fs_created_queue = set()
self._fs_created_timer = QTimer(self) self._fs_created_timer = QTimer(self)
@@ -1758,31 +1760,47 @@ class MainWindow(QMainWindow):
menu.addSeparator() menu.addSeparator()
duplicates_menu = menu.addMenu(QIcon.fromTheme("edit-find-replace"), UITexts.MENU_DUPLICATES) duplicates_menu = menu.addMenu(
QIcon.fromTheme("edit-find-replace"), UITexts.MENU_DUPLICATES)
duplicates_menu.setEnabled(HAVE_IMAGEHASH) duplicates_menu.setEnabled(HAVE_IMAGEHASH)
detect_current_action = duplicates_menu.addAction(UITexts.MENU_DETECT_CURRENT_SEARCH) detect_current_action = duplicates_menu.addAction(
UITexts.MENU_DETECT_CURRENT_SEARCH)
detect_current_action.triggered.connect(self.start_duplicate_detection) detect_current_action.triggered.connect(self.start_duplicate_detection)
force_full_action = duplicates_menu.addAction(UITexts.MENU_FORCE_FULL_ANALYSIS)
force_full_action.triggered.connect(
lambda: self.start_duplicate_detection(force_full=True))
detect_all_action = duplicates_menu.addAction(UITexts.MENU_DETECT_ALL) detect_all_action = duplicates_menu.addAction(UITexts.MENU_DETECT_ALL)
detect_all_action.triggered.connect(self.detect_all_duplicates) detect_all_action.triggered.connect(self.detect_all_duplicates)
force_full_action = duplicates_menu.addAction(UITexts.MENU_FORCE_FULL_ANALYSIS) force_full_all_action = duplicates_menu.addAction(
force_full_action.triggered.connect(lambda: self.start_duplicate_detection(force_full=True)) UITexts.MENU_FORCE_FULL_ALL_ANALYSIS)
force_full_all_action.triggered.connect(
lambda: self.detect_all_duplicates(force_full=True))
review_ignored_action = duplicates_menu.addAction(UITexts.MENU_REVIEW_IGNORED) review_ignored_action = duplicates_menu.addAction(UITexts.MENU_REVIEW_IGNORED)
review_ignored_action.triggered.connect(self.review_ignored_duplicates) review_ignored_action.triggered.connect(self.review_ignored_duplicates)
duplicates_menu.addSeparator() duplicates_menu.addSeparator()
clean_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("edit-clear-all"), clean_hashes_action = duplicates_menu.addAction(
UITexts.MENU_CLEAN_UP_HASHES) QIcon.fromTheme("edit-clear-all"), UITexts.MENU_CLEAN_UP_HASHES)
clean_hashes_action.triggered.connect(self.clean_duplicate_hashes) clean_hashes_action.triggered.connect(self.clean_duplicate_hashes)
repair_index_action = duplicates_menu.addAction(UITexts.MENU_REPAIR_DATABASE)
repair_index_action.triggered.connect(self.repair_duplicate_index)
clear_exceptions_action = duplicates_menu.addAction(
UITexts.MENU_CLEAR_EXCEPTIONS)
clear_exceptions_action.triggered.connect(self.clear_ignored_duplicates)
if self.duplicate_cache: if self.duplicate_cache:
count, size_bytes = self.duplicate_cache.get_hash_stats() count, size_bytes = self.duplicate_cache.get_hash_stats()
size_mb = size_bytes / (1024 * 1024) size_mb = size_bytes / (1024 * 1024)
clear_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("user-trash-full"), clear_hashes_action = duplicates_menu.addAction(
QIcon.fromTheme("user-trash-full"),
UITexts.MENU_CLEAR_HASHES.format(count, size_mb)) UITexts.MENU_CLEAR_HASHES.format(count, size_mb))
clear_hashes_action.triggered.connect(self.clear_duplicate_hashes) clear_hashes_action.triggered.connect(self.clear_duplicate_hashes)
@@ -1822,7 +1840,7 @@ class MainWindow(QMainWindow):
menu.exec(self.menu_btn.mapToGlobal(QPoint(0, self.menu_btn.height()))) menu.exec(self.menu_btn.mapToGlobal(QPoint(0, self.menu_btn.height())))
def detect_all_duplicates(self): def detect_all_duplicates(self, force_full=False):
"""Gathers files from whitelist (respecting blacklist) and runs detector.""" """Gathers files from whitelist (respecting blacklist) and runs detector."""
QApplication.setOverrideCursor(Qt.WaitCursor) QApplication.setOverrideCursor(Qt.WaitCursor)
try: try:
@@ -1831,22 +1849,29 @@ class MainWindow(QMainWindow):
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
if paths is None: if paths is None:
QMessageBox.warning(self, UITexts.WARNING, "Whitelist is empty. Please configure it in Settings.") QMessageBox.warning(
self, UITexts.WARNING,
UITexts.DUPLICATE_WHITELIST_EMPTY)
return return
if not paths: if not paths:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) QMessageBox.information(
self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
return return
self.start_duplicate_detection(custom_paths=paths) # By default, we use optimized (incremental) mode to avoid repeating
# comparisons.
self.start_duplicate_detection(force_full=force_full, custom_paths=paths)
def _gather_files_for_duplicates(self): def _gather_files_for_duplicates(self):
"""Helper to collect image paths based on whitelist and blacklist settings.""" """Helper to collect image paths based on whitelist and blacklist settings."""
whitelist_str = APP_CONFIG.get("duplicate_whitelist", "") whitelist_str = APP_CONFIG.get("duplicate_whitelist", "")
blacklist_str = APP_CONFIG.get("duplicate_blacklist", "") blacklist_str = APP_CONFIG.get("duplicate_blacklist", "")
whitelist = [os.path.abspath(os.path.expanduser(p.strip())) for p in whitelist_str.split(',') if p.strip()] whitelist = [os.path.abspath(os.path.expanduser(p.strip()))
blacklist = [os.path.abspath(os.path.expanduser(p.strip())) for p in blacklist_str.split(',') if p.strip()] for p in whitelist_str.split(',') if p.strip()]
blacklist = [os.path.abspath(os.path.expanduser(p.strip()))
for p in blacklist_str.split(',') if p.strip()]
if not whitelist: if not whitelist:
return None return None
@@ -1861,7 +1886,8 @@ class MainWindow(QMainWindow):
for root, dirs, files in os.walk(root_path): for root, dirs, files in os.walk(root_path):
abs_root = os.path.abspath(root) abs_root = os.path.abspath(root)
# Prune dirs to stop walking into blacklisted paths # Prune dirs to stop walking into blacklisted paths
dirs[:] = [d for d in dirs if os.path.join(abs_root, d) not in blacklist_set] dirs[:] = [d for d in dirs
if os.path.join(abs_root, d) not in blacklist_set]
if abs_root in blacklist_set: if abs_root in blacklist_set:
continue continue
@@ -1878,6 +1904,40 @@ class MainWindow(QMainWindow):
count = self.duplicate_cache.clean_stale_hashes() count = self.duplicate_cache.clean_stale_hashes()
self.status_lbl.setText(f"Cleaned up {count} stale hash entries.") self.status_lbl.setText(f"Cleaned up {count} stale hash entries.")
def repair_duplicate_index(self):
"""Regenerates the BK-Tree and reverse index."""
if not self.duplicate_cache:
return
if self.duplicate_detector and self.duplicate_detector.isRunning():
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE,
UITexts.DUPLICATE_ALREADY_RUNNING)
return
self.status_lbl.setText(UITexts.REPAIRING_DATABASE)
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
self.duplicate_cache.regenerate_bktree()
self.status_lbl.setText(UITexts.READY)
finally:
QApplication.restoreOverrideCursor()
def clear_ignored_duplicates(self):
"""Clears the ignored pairs database after user confirmation."""
if not self.duplicate_cache:
return
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Question)
confirm.setWindowTitle(UITexts.CONFIRM_CLEAR_EXCEPTIONS_TITLE)
confirm.setText(UITexts.CONFIRM_CLEAR_EXCEPTIONS_TEXT)
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() == QMessageBox.Yes:
self.duplicate_cache.clear_exceptions()
self.status_lbl.setText(UITexts.READY)
def clear_duplicate_hashes(self): def clear_duplicate_hashes(self):
if not self.duplicate_cache: if not self.duplicate_cache:
return return
@@ -1900,9 +1960,11 @@ class MainWindow(QMainWindow):
return return
ignored = self.duplicate_cache.get_all_exceptions() ignored = self.duplicate_cache.get_all_exceptions()
if not ignored: if not ignored:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) QMessageBox.information(
self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
return return
dialog = DuplicateManagerDialog(ignored, self.duplicate_cache, self, review_mode=True) dialog = DuplicateManagerDialog(
ignored, self.duplicate_cache, self, review_mode=True)
dialog.show() dialog.show()
def show_about_dialog(self): def show_about_dialog(self):
@@ -2010,6 +2072,9 @@ class MainWindow(QMainWindow):
def perform_shutdown(self): def perform_shutdown(self):
"""Performs cleanup operations before the application closes.""" """Performs cleanup operations before the application closes."""
if getattr(self, '_is_shutting_down', False):
return
self._is_shutting_down = True
self.is_cleaning = True self.is_cleaning = True
# Save configuration early if visible, as per user request. # Save configuration early if visible, as per user request.
@@ -2017,6 +2082,24 @@ class MainWindow(QMainWindow):
if self.isVisible(): if self.isVisible():
self.save_config() self.save_config()
# Stop all pending UI timers to prevent background tasks from triggering
self.hide_progress_timer.stop()
self.filter_input_timer.stop()
self.filter_refresh_timer.stop()
self.thumbnails_refresh_timer.stop()
self.rebuild_timer.stop()
self._model_update_timer.stop()
self.resume_scan_timer.stop()
# Close all viewers first to stop their preloader threads
self.close_all_viewers()
# Close duplicate manager if open to stop its preloader threads
if HAVE_IMAGEHASH:
for widget in QApplication.topLevelWidgets():
if isinstance(widget, DuplicateManagerDialog):
widget.close()
self.fs_watcher.stop() self.fs_watcher.stop()
# 1. Stop all worker threads interacting with the cache # 1. Stop all worker threads interacting with the cache
@@ -2053,7 +2136,19 @@ class MainWindow(QMainWindow):
# Ensure all QRunnables in the shared thread pool are finished # Ensure all QRunnables in the shared thread pool are finished
if self.thread_pool_manager: if self.thread_pool_manager:
self.thread_pool_manager.get_pool().waitForDone() pool = self.thread_pool_manager.get_pool()
if pool.activeThreadCount() > 0:
progress = QProgressDialog(UITexts.SHUTTING_DOWN, None, 0, 0, self)
progress.setWindowTitle(PROG_NAME)
progress.setWindowModality(Qt.ApplicationModal)
progress.setMinimumDuration(0)
progress.show()
# Wait in small increments to keep the UI responsive
while not pool.waitForDone(100):
if QApplication.instance():
QApplication.processEvents()
progress.close()
if self.duplicate_cache: if self.duplicate_cache:
self.duplicate_cache.lmdb_close() self.duplicate_cache.lmdb_close()
@@ -2162,6 +2257,9 @@ class MainWindow(QMainWindow):
current_vis_row = current_proxy_idx.row() current_vis_row = current_proxy_idx.row()
total_visible = self.proxy_model.rowCount() total_visible = self.proxy_model.rowCount()
if total_visible == 0:
return
grid_size = self.thumbnail_view.gridSize() grid_size = self.thumbnail_view.gridSize()
if grid_size.width() == 0: if grid_size.width() == 0:
return return
@@ -2178,7 +2276,7 @@ class MainWindow(QMainWindow):
elif e.key() == Qt.Key_Up: elif e.key() == Qt.Key_Up:
next_vis_row -= cols next_vis_row -= cols
elif e.key() in (Qt.Key_Return, Qt.Key_Enter): elif e.key() in (Qt.Key_Return, Qt.Key_Enter):
if current_proxy_idx.isValid(): if current_proxy_idx and current_proxy_idx.isValid():
self.open_viewer(current_proxy_idx) self.open_viewer(current_proxy_idx)
return return
else: else:
@@ -2395,7 +2493,8 @@ class MainWindow(QMainWindow):
confirm.setInformativeText( confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(paths[0]))) UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(paths[0])))
else: else:
confirm.setText(f"Are you sure you want to permanently delete {len(paths)} images?") confirm.setText(
f"Are you sure you want to permanently delete {len(paths)} images?")
confirm.setInformativeText("This action CANNOT be undone.") confirm.setInformativeText("This action CANNOT be undone.")
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No) confirm.setDefaultButton(QMessageBox.No)
@@ -2689,7 +2788,8 @@ class MainWindow(QMainWindow):
self.is_cleaning = False self.is_cleaning = False
self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all, self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all,
thread_pool_manager=self.thread_pool_manager, thread_pool_manager=self.thread_pool_manager,
viewers=self.viewers) viewers=self.viewers,
target_sizes=[self._current_thumb_tier])
if self._is_loading_all: if self._is_loading_all:
self.scanner.set_auto_load(True) self.scanner.set_auto_load(True)
self._is_loading = True self._is_loading = True
@@ -2750,7 +2850,6 @@ class MainWindow(QMainWindow):
self._scanner_total_files = count self._scanner_total_files = count
self._is_loading = False self._is_loading = False
has_more = i < count has_more = i < count
self.btn_load_more.setVisible(has_more)
self.btn_load_all.setVisible(has_more) self.btn_load_all.setVisible(has_more)
def request_more_images(self, amount): def request_more_images(self, amount):
@@ -3155,8 +3254,8 @@ class MainWindow(QMainWindow):
current_item = self.thumbnail_model.item(model_idx) current_item = self.thumbnail_model.item(model_idx)
if self._match_item(target, current_item): if self._match_item(target, current_item):
# Si es una cabecera, actualizamos el texto por si cambió el # If it is a header, update the text in case the counter
# contador # changed.
if isinstance(target, tuple) and target[0] == 'HEADER': if isinstance(target, tuple) and target[0] == 'HEADER':
_, (_, header_text, _) = target _, (_, header_text, _) = target
if current_item.data(DIR_ROLE) != header_text: if current_item.data(DIR_ROLE) != header_text:
@@ -3367,7 +3466,7 @@ class MainWindow(QMainWindow):
"""Updates the circular progress bar value.""" """Updates the circular progress bar value."""
self.progress_bar.setValue(value) self.progress_bar.setValue(value)
def on_thumbnail_loaded(self, path, size): def on_thumbnail_loaded(self, _path, _size):
"""Called when a thumbnail has been loaded asynchronously from DB.""" """Called when a thumbnail has been loaded asynchronously from DB."""
self.thumbnail_view.viewport().update() self.thumbnail_view.viewport().update()
@@ -3822,7 +3921,8 @@ class MainWindow(QMainWindow):
self._setup_viewer_sync(viewer) self._setup_viewer_sync(viewer)
self.viewers.append(viewer) self.viewers.append(viewer)
viewer.destroyed.connect( viewer.destroyed.connect(
lambda obj=viewer: self.viewers.remove(obj) if obj in self.viewers else None) lambda obj=viewer: self.viewers.remove(obj)
if obj in self.viewers else None)
if len(paths) > 1: if len(paths) > 1:
viewer.set_comparison_mode(len(paths)) viewer.set_comparison_mode(len(paths))
@@ -3947,14 +4047,14 @@ class MainWindow(QMainWindow):
self.thumbnail_view.setGridSize(QSize()) self.thumbnail_view.setGridSize(QSize())
else: else:
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
self.rebuild_view() self.rebuild_view(full_reset=True)
self.save_config() self.save_config()
self.setFocus() self.setFocus()
def on_sort_changed(self): def on_sort_changed(self):
"""Callback for when the sort order dropdown changes.""" """Callback for when the sort order dropdown changes."""
self.rebuild_view() self.rebuild_view(full_reset=True)
self.save_config() self.save_config()
if hasattr(self, 'history_tab'): if hasattr(self, 'history_tab'):
self.history_tab.refresh_list() self.history_tab.refresh_list()
@@ -3979,10 +4079,9 @@ class MainWindow(QMainWindow):
if new_tier != self._current_thumb_tier: if new_tier != self._current_thumb_tier:
self._current_thumb_tier = new_tier self._current_thumb_tier = new_tier
# 1. Update the list of sizes for the main scanner to generate for # Update scanner if running to use the new tier for upcoming batches
# any NEW images (e.g., from scrolling down). It will now only if self.scanner and self.scanner.isRunning():
# generate the tier needed for the current view. self.scanner.target_sizes = [new_tier]
# SCANNER_GENERATE_SIZES = [new_tier]
# 2. For all images ALREADY loaded, start a background job to # 2. For all images ALREADY loaded, start a background job to
# generate the newly required thumbnail size. This is interruptible. # generate the newly required thumbnail size. This is interruptible.
@@ -4401,13 +4500,15 @@ class MainWindow(QMainWindow):
UITexts.CONTEXT_MENU_OPEN) UITexts.CONTEXT_MENU_OPEN)
full_path = os.path.abspath( full_path = os.path.abspath(
self.proxy_model.data(selected_indexes[0], PATH_ROLE)) self.proxy_model.data(selected_indexes[0], PATH_ROLE))
p_data = self.proxy_model.data(selected_indexes[0], PATH_ROLE)
full_path = os.path.abspath(p_data) if p_data else ""
self.populate_open_with_submenu(open_submenu, full_path) self.populate_open_with_submenu(open_submenu, full_path)
# New action: Open in Fullscreen Viewer action_open_fullscreen = menu.addAction(
action_open_fullscreen = open_submenu.addAction(
QIcon.fromTheme("view-fullscreen"), QIcon.fromTheme("view-fullscreen"),
UITexts.CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER) UITexts.CONTEXT_MENU_FULLSCREEN_VIEWER)
action_open_fullscreen.triggered.connect(lambda: self.open_in_fullscreen_viewer(selected_indexes[0])) action_open_fullscreen.triggered.connect(
lambda: self.open_in_fullscreen_viewer(selected_indexes[0]))
path = self.proxy_model.data(selected_indexes[0], PATH_ROLE) path = self.proxy_model.data(selected_indexes[0], PATH_ROLE)
action_open_location = menu.addAction(QIcon.fromTheme("folder-search"), action_open_location = menu.addAction(QIcon.fromTheme("folder-search"),
@@ -4613,11 +4714,11 @@ class MainWindow(QMainWindow):
lambda checked=False, df=desktop_file_id_from_gio_mime: lambda checked=False, df=desktop_file_id_from_gio_mime:
subprocess.Popen(["gtk-launch", df, full_path])) subprocess.Popen(["gtk-launch", df, full_path]))
menu.addSeparator() # menu.addSeparator()
action_other = menu.addAction(QIcon.fromTheme("applications-other"), # action_other = menu.addAction(QIcon.fromTheme("applications-other"),
"Open with other application...") # UITexts.OPEN_WITH_OTHER)
action_other.triggered.connect( # action_other.triggered.connect(
lambda: self.open_with_system_chooser(full_path)) # lambda: self.open_with_system_chooser(full_path))
except Exception: except Exception:
action = menu.addAction(UITexts.CONTEXT_MENU_ERROR_LISTING_APPS) action = menu.addAction(UITexts.CONTEXT_MENU_ERROR_LISTING_APPS)
action.setEnabled(False) action.setEnabled(False)
@@ -4820,22 +4921,58 @@ class MainWindow(QMainWindow):
if path not in self._known_paths: if path not in self._known_paths:
return # Not a file we're tracking return # Not a file we're tracking
# Invalidate cache and trigger a refresh of its metadata and thumbnail # If this modification was initiated by the app, ignore it
self.cache.invalidate_path(path) if path in self._paths_being_modified_by_app:
return
# Re-read metadata and thumbnail # External modification: check if it's metadata-only or content change
try:
new_stat = os.stat(path)
new_mtime = new_stat.st_mtime
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_mtime = old_item_data[2] if old_item_data else 0
# 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) res = load_common_metadata(path)
mtime = os.path.getmtime(path) self._update_internal_data(
stat_res = os.stat(path) path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode = stat_res.st_ino inode=new_stat.st_ino, dev=new_stat.st_dev)
dev = stat_res.st_dev self.proxy_model.add_to_cache(path, res.tags)
self.thumbnail_view.viewport().update() # Force repaint
# Update internal data and model self.status_lbl.setText(f"Metadata updated: {os.path.basename(path)}")
self._update_internal_data(path, mtime=mtime, tags=res.tags, rating=res.rating, else:
inode=inode, dev=dev) # 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)
self.proxy_model.add_to_cache(path, res.tags) self.proxy_model.add_to_cache(path, res.tags)
self.rebuild_view() self.rebuild_view()
self.status_lbl.setText(f"File modified: {os.path.basename(path)}") self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
except Exception:
# Fallback to full refresh if error occurs
self.refresh_content()
def _mark_path_as_app_modified(self, path):
"""Marks a path as being modified by the application to ignore FS events."""
abs_path = os.path.abspath(path)
parent_path = os.path.dirname(abs_path)
self._paths_being_modified_by_app.add(abs_path)
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))
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."""
@@ -4850,6 +4987,9 @@ class MainWindow(QMainWindow):
"""Handles a directory being modified (e.g., new subfolder, mass changes).""" """Handles a directory being modified (e.g., new subfolder, mass changes)."""
path = os.path.abspath(path) path = os.path.abspath(path)
if path in self._paths_being_modified_by_app:
return
# Trigger a debounced full refresh. This is useful for syncing large # Trigger a debounced full refresh. This is useful for syncing large
# external changes (bulk operations, directory deletions) that are # external changes (bulk operations, directory deletions) that are
# more robustly handled by a full scan than incremental updates. # more robustly handled by a full scan than incremental updates.
@@ -5006,10 +5146,10 @@ class MainWindow(QMainWindow):
def update_load_all_button_state(self): def update_load_all_button_state(self):
"""Updates the text and tooltip of the 'load all' button based on its state.""" """Updates the text and tooltip of the 'load all' button based on its state."""
if self._is_loading_all: if self._is_loading_all:
self.btn_load_all.setText("X") self.btn_load_all.setIcon(QIcon.fromTheme("process-stop"))
self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP_ALT) self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP_ALT)
else: else:
self.btn_load_all.setText("+a") self.btn_load_all.setIcon(QIcon.fromTheme("media-playback-start"))
self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP) self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP)
def _create_language_menu(self): def _create_language_menu(self):
@@ -5056,7 +5196,8 @@ class MainWindow(QMainWindow):
return return
# Get all image paths currently known to the application or provided list # Get all image paths currently known to the application or provided list
paths_to_scan = custom_paths if custom_paths is not None else self.get_all_image_paths() paths_to_scan = custom_paths \
if custom_paths is not None else self.get_all_image_paths()
if not paths_to_scan: if not paths_to_scan:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE,
UITexts.DUPLICATE_NO_IMAGES) UITexts.DUPLICATE_NO_IMAGES)
@@ -5067,11 +5208,15 @@ class MainWindow(QMainWindow):
threshold = APP_CONFIG.get("duplicate_threshold", 90) threshold = APP_CONFIG.get("duplicate_threshold", 90)
self.duplicate_detector = DuplicateDetector( self.duplicate_detector = DuplicateDetector(
paths_to_scan, self.duplicate_cache, self.thread_pool_manager, method, threshold, force_full=force_full) paths_to_scan, self.duplicate_cache,
self.thread_pool_manager, method, threshold, force_full=force_full)
self.duplicate_detector.progress_update.connect(self.on_duplicate_detection_progress) self.duplicate_detector.progress_update.connect(
self.duplicate_detector.duplicates_found.connect(self.on_duplicates_found) self.on_duplicate_detection_progress)
self.duplicate_detector.detection_finished.connect(self.on_duplicate_detection_finished) self.duplicate_detector.duplicates_found.connect(
self.on_duplicates_found)
self.duplicate_detector.detection_finished.connect(
self.on_duplicate_detection_finished)
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
self.progress_bar.setCustomColor(None) self.progress_bar.setCustomColor(None)
@@ -5139,11 +5284,6 @@ def main():
thread_pool_manager = ThreadPoolManager() thread_pool_manager = ThreadPoolManager()
cache = ThumbnailCache() cache = ThumbnailCache()
args = [a for a in sys.argv[1:] if a != "--x11"] args = [a for a in sys.argv[1:] if a != "--x11"]
if args:
path = " ".join(args).strip()
if path.startswith("file:/"):
path = path[6:]
win = MainWindow(cache, args, thread_pool_manager, duplicate_cache) win = MainWindow(cache, args, thread_pool_manager, duplicate_cache)
app.installEventFilter(win.shortcut_controller) app.installEventFilter(win.shortcut_controller)

View File

@@ -1,3 +1,7 @@
¿Sería posible añadir una opción para limpiar automáticamente los hashes de archivos que ya no existen sin borrar toda la base de datos?
¿Podrías optimizar el proceso de borrado en lote para que sea más eficiente si hay miles de entradas que limpiar?
Implement a bulk rename feature for the selected pet or face tags. Implement a bulk rename feature for the selected pet or face tags.
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text. Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
@@ -63,6 +67,15 @@ Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligent
¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista? ¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista?
v0.9.18 -
· Better messages
v0.9.17 -
· Fixes
v0.9.16 -
· Fixes
v0.9.15 - v0.9.15 -
· Duplicates · Duplicates

View File

@@ -29,7 +29,7 @@ if FORCE_X11:
# --- CONFIGURATION --- # --- CONFIGURATION ---
PROG_NAME = "Bagheera Image Viewer" PROG_NAME = "Bagheera Image Viewer"
PROG_ID = "bagheeraview" PROG_ID = "bagheeraview"
PROG_VERSION = "0.9.16" PROG_VERSION = "0.9.26"
PROG_AUTHOR = "Ignacio Serantes" PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS --- # --- CACHE SETTINGS ---
@@ -57,21 +57,26 @@ DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024
# --- PATHS --- # --- PATHS ---
CONFIG_FILE = f"{PROG_ID}rc" CONFIG_FILE = f"{PROG_ID}rc"
CONFIG_LOCATION = '.config/iserantes' CONFIG_LOCATION = os.environ.get('XDG_CONFIG_HOME')
CONFIG_DIR = os.path.join(os.path.expanduser("~"), CONFIG_LOCATION, PROG_ID) CONFIG_DIR = os.path.join(CONFIG_LOCATION, 'iserantes', PROG_ID)
CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILE) CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILE)
CACHE_PATH = os.path.join(CONFIG_DIR, "thumbnails")
APP_DATA_LOCATION = os.path.expanduser('~/.local/share')
APP_DATA_DIR = os.path.join(APP_DATA_LOCATION, 'iserantes', PROG_ID)
CACHE_PATH = os.path.join(APP_DATA_DIR, "thumbnails")
HISTORY_FILE = "history.json" HISTORY_FILE = "history.json"
HISTORY_PATH = os.path.join(CONFIG_DIR, HISTORY_FILE) HISTORY_PATH = os.path.join(APP_DATA_DIR, HISTORY_FILE)
LAYOUTS_DIR = os.path.join(CONFIG_DIR, "layouts") # Layouts saving directory LAYOUTS_DIR = os.path.join(APP_DATA_DIR, "layouts") # Layouts saving directory
FAVORITES_FILE = "favorites.json" FAVORITES_FILE = "favorites.json"
FAVORITES_PATH = os.path.join(CONFIG_DIR, FAVORITES_FILE) FAVORITES_PATH = os.path.join(APP_DATA_DIR, FAVORITES_FILE)
FAVORITES_PATH = os.path.join(CONFIG_DIR, FAVORITES_FILE) DUPLICATE_CACHE_PATH = os.path.join(APP_DATA_DIR, "duplicates")
DUPLICATE_CACHE_PATH = os.path.join(CONFIG_DIR, "duplicates")
DUPLICATE_HASH_DB_NAME = b"hashes" DUPLICATE_HASH_DB_NAME = b"hashes"
DUPLICATE_EXCEPTIONS_DB_NAME = b"exceptions" DUPLICATE_EXCEPTIONS_DB_NAME = b"exceptions"
DUPLICATE_PENDING_DB_NAME = b"pending" DUPLICATE_PENDING_DB_NAME = b"pending"
DUPLICATE_BKTREE_DB_NAME = b"bktree"
DUPLICATE_HASH_TO_FILES_DB_NAME = b"hash_to_files"
def save_app_config(): def save_app_config():
@@ -138,6 +143,11 @@ SCANNER_SETTINGS_DEFAULTS = {
"person_tags": "", "person_tags": "",
"generation_threads": 4, "generation_threads": 4,
"search_engine": "", "search_engine": "",
"face_use_last_name": False,
"pet_use_last_name": False,
"body_use_last_name": False,
"object_use_last_name": False,
"landmark_use_last_name": False,
"duplicate_threshold": 90, # Similarity percentage (50-100) "duplicate_threshold": 90, # Similarity percentage (50-100)
"duplicate_method": "histogram_hashing", "duplicate_method": "histogram_hashing",
"duplicate_confirm_delete": True, "duplicate_confirm_delete": True,
@@ -200,15 +210,15 @@ if importlib.util.find_spec("mediapipe") is not None:
pass pass
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR, MEDIAPIPE_FACE_MODEL_PATH = os.path.join(
"blaze_face_short_range.tflite") APP_DATA_DIR, "blaze_face_short_range.tflite")
MEDIAPIPE_FACE_MODEL_URL = ( MEDIAPIPE_FACE_MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/face_detector/" "https://storage.googleapis.com/mediapipe-models/face_detector/"
"blaze_face_short_range/float16/1/blaze_face_short_range.tflite" "blaze_face_short_range/float16/1/blaze_face_short_range.tflite"
) )
MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(CONFIG_DIR, MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(
"efficientdet_lite0.tflite") APP_DATA_DIR, "efficientdet_lite0.tflite")
MEDIAPIPE_OBJECT_MODEL_URL = ( MEDIAPIPE_OBJECT_MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/object_detector/" "https://storage.googleapis.com/mediapipe-models/object_detector/"
"efficientdet_lite0/float16/1/efficientdet_lite0.tflite" "efficientdet_lite0/float16/1/efficientdet_lite0.tflite"
@@ -394,6 +404,7 @@ _UI_TEXTS = {
"SEARCH": "Search", "SEARCH": "Search",
"SELECT": "Select", "SELECT": "Select",
"ERROR": "Error", "ERROR": "Error",
"FILE_NOT_FOUND": "File not found",
"WARNING": "Warning", "WARNING": "Warning",
"INFO": "Info", "INFO": "Info",
"LOAD": "Load", "LOAD": "Load",
@@ -514,30 +525,51 @@ _UI_TEXTS = {
"MENU_DUPLICATES": "Duplicates", "MENU_DUPLICATES": "Duplicates",
"MENU_DETECT_CURRENT_SEARCH": "Detect in current search", "MENU_DETECT_CURRENT_SEARCH": "Detect in current search",
"MENU_DETECT_ALL": "Detect all", "MENU_DETECT_ALL": "Detect all",
"MENU_FORCE_FULL_ALL_ANALYSIS": "Force full all analysis",
"MENU_FORCE_FULL_ANALYSIS": "Force full analysis", "MENU_FORCE_FULL_ANALYSIS": "Force full analysis",
"MENU_REVIEW_IGNORED": "Review ignored", "MENU_REVIEW_IGNORED": "Review ignored",
"MENU_CLEAN_UP_HASHES": "Clean up", "MENU_CLEAN_UP_HASHES": "Clean up",
"MENU_REPAIR_DATABASE": "Repair index",
"MENU_CLEAR_EXCEPTIONS": "Clear ignored pairs",
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirm Clear Ignored Pairs",
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "Are you sure you want to clear all "
"ignored duplicate pairs? They will be detected again in the next scan.",
"REPAIRING_DATABASE": "Repairing duplicate index...",
"MENU_CLEAR_HASHES": "Clear hashes ({} items, {:.1f} MB on disk)", "MENU_CLEAR_HASHES": "Clear hashes ({} items, {:.1f} MB on disk)",
"CONFIRM_CLEAR_HASHES_TITLE": "Confirm Clear Hashes", "CONFIRM_CLEAR_HASHES_TITLE": "Confirm Clear Hashes",
"CONFIRM_CLEAR_HASHES_TEXT": "Are you sure you want to permanently delete the entire hash database?", "CONFIRM_CLEAR_HASHES_TEXT": "Are you sure you want to permanently delete "
"CONFIRM_CLEAR_HASHES_INFO": "This will remove all calculated image hashes. They will be recalculated as you detect duplicates, which may be slow. This action cannot be undone.", "the entire hash database?",
"CONFIRM_CLEAR_HASHES_INFO": "This will remove all calculated image hashes. "
"They will be recalculated as you detect duplicates, which may be slow. This "
"action cannot be undone.",
"SETTINGS_DUPLICATE_METHOD_LABEL": "Method:", "SETTINGS_DUPLICATE_METHOD_LABEL": "Method:",
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Select the method for duplicate detection.", "SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Select the method for duplicate "
"detection.",
"METHOD_HISTOGRAM_HASHING": "Histogram + Hashing", "METHOD_HISTOGRAM_HASHING": "Histogram + Hashing",
"METHOD_RESNET": "ResNet (AI Based)", "METHOD_RESNET": "ResNet (AI Based)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates", "SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates",
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Whitelist (folders to include):", "SETTINGS_DUPLICATE_WHITELIST_LABEL": "Whitelist (folders to include):",
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Comma-separated paths of folders to scan when using 'Detect all'.", "SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Comma-separated paths of folders to "
"scan when using 'Detect all'.",
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Blacklist (folders to exclude):", "SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Blacklist (folders to exclude):",
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Comma-separated paths of folders to ignore during 'Detect all' scans.", "SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Comma-separated paths of folders to "
"ignore during 'Detect all' scans.",
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Images found for 'Detect all': {}", "SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Images found for 'Detect all': {}",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by default", "SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by "
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "If checked, pressing the Delete key will move files to trash. If unchecked, it will permanently delete them.", "default",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Show a confirmation dialog before moving a duplicate image to the trash.", "SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "If checked, pressing the Delete "
"key will move files to trash. If unchecked, it will permanently delete them.",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Show a confirmation dialog "
"before moving a duplicate image to the trash.",
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Similarity Threshold:", "SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Similarity Threshold:",
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Set the similarity threshold (50-100%). Higher values mean images must be more similar to be considered duplicates.", "SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Set the similarity threshold 2 "
"SETTINGS_DUPLICATE_MISSING_LIBS": "The 'imagehash' library is required for duplicate detection but was not found. This feature is disabled.", "(50-100%). Higher values mean images must be more similar to be considered "
"duplicates.",
"SETTINGS_DUPLICATE_MISSING_LIBS": "The 'imagehash' library is required for "
"duplicate detection but was not found. This feature is disabled.",
"MENU_DETECT_DUPLICATES": "Detect Duplicates", "MENU_DETECT_DUPLICATES": "Detect Duplicates",
"DUPLICATE_WHITELIST_EMPTY": "Whitelist is empty. Please configure it "
"in Settings.",
"DUPLICATE_DETECTION_TITLE": "Duplicate Detection", "DUPLICATE_DETECTION_TITLE": "Duplicate Detection",
"DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.", "DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.",
"DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.", "DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.",
@@ -562,6 +594,7 @@ _UI_TEXTS = {
"VIEWER_MENU_LINK_PANES": "Link Panes", "VIEWER_MENU_LINK_PANES": "Link Panes",
"DUPLICATE_OPEN_COMPARISON": "Open Comparison", "DUPLICATE_OPEN_COMPARISON": "Open Comparison",
"DUPLICATE_LIST_HEADER": "Duplicate Pairs", "DUPLICATE_LIST_HEADER": "Duplicate Pairs",
"IGNORED_DATE": "Ignored Date",
"SETTINGS_GROUP_SCANNER": "Scanner", "SETTINGS_GROUP_SCANNER": "Scanner",
"SETTINGS_GROUP_AREAS": "Areas", "SETTINGS_GROUP_AREAS": "Areas",
"SETTINGS_GROUP_THUMBNAILS": "Thumbnails", "SETTINGS_GROUP_THUMBNAILS": "Thumbnails",
@@ -617,6 +650,11 @@ _UI_TEXTS = {
"landmarks.", "landmarks.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Maximum number of recently used " "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Maximum number of recently used "
"landmark names to remember.", "landmark names to remember.",
"SETTINGS_PATH_NOT_FOUND_WARNING": "Warning: Path not found or is not "
"a directory: {}",
"SETTINGS_USE_LAST_NAME_LABEL": "Use last name by default",
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Automatically fill the assignment window "
"with the last used name.",
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Max face history:", "SETTINGS_FACE_HISTORY_COUNT_LABEL": "Max face history:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Thumbs refresh interval (ms):", "SETTINGS_THUMBS_REFRESH_LABEL": "Thumbs refresh interval (ms):",
"MENU_VIEWER_SETTINGS": "Viewer Settings", "MENU_VIEWER_SETTINGS": "Viewer Settings",
@@ -757,6 +795,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "File '{}' already exists.", "RENAME_ERROR_EXISTS": "File '{}' already exists.",
"FILE_RENAMED": "File renamed to {}", "FILE_RENAMED": "File renamed to {}",
"ERROR_RENAME": "Could not rename file: {}", "ERROR_RENAME": "Could not rename file: {}",
"ERROR_JPEG_METADATA_LIMIT": "Metadata size limit exceeded for '{}'. This "
"JPEG file has too much existing metadata (XMP) to save more.",
"MAIN_DOCK_TITLE": "", "MAIN_DOCK_TITLE": "",
"LAYOUTS_TAB": "Layouts", "LAYOUTS_TAB": "Layouts",
"LAYOUTS_TABLE_HEADER": ["Name", "Last Modified"], "LAYOUTS_TABLE_HEADER": ["Name", "Last Modified"],
@@ -793,6 +833,8 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 ALL TAGS", "TAG_ALL_TAGS": "📂 ALL TAGS",
"TAG_NEW_TAG_TITLE": "New Tag", "TAG_NEW_TAG_TITLE": "New Tag",
"SEARCH_BY_TAG": "Search by this tag", "SEARCH_BY_TAG": "Search by this tag",
"TAG_ADD_TOOLTIP": "Create a new tag",
"TAG_REFRESH_TOOLTIP": "Refresh available tags from Baloo database",
"TAG_NEW_TAG_TEXT": "Enter tag name (use / for hierarchy):", "TAG_NEW_TAG_TEXT": "Enter tag name (use / for hierarchy):",
"SEARCH_ADD_AND": "Add AND this tag to search", "SEARCH_ADD_AND": "Add AND this tag to search",
"SEARCH_ADD_OR": "Add OR this tag to search", "SEARCH_ADD_OR": "Add OR this tag to search",
@@ -827,6 +869,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Property", "Value"], "PROPERTIES_TABLE_HEADER": ["Property", "Value"],
"PROPERTIES_ADD_ATTR": "Add Attribute", "PROPERTIES_ADD_ATTR": "Add Attribute",
"PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):", "PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):",
"PROPERTIES_DELETE_ALL": "Delete All",
"PROPERTIES_ADD_ATTR_VALUE": "Value for {}:", "PROPERTIES_ADD_ATTR_VALUE": "Value for {}:",
"PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}", "PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}",
@@ -879,7 +922,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_OPEN": "Open", "CONTEXT_MENU_OPEN": "Open",
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location", "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location",
"CONTEXT_MENU_OPEN_DEFAULT_APP": "Open location with default application", "CONTEXT_MENU_OPEN_DEFAULT_APP": "Open location with default application",
"CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER": "Open in Fullscreen Viewer", "CONTEXT_MENU_FULLSCREEN_VIEWER": "Open in Fullscreen Viewer",
"CONTEXT_MENU_MOVE_TO": "Move to...", "CONTEXT_MENU_MOVE_TO": "Move to...",
"CONTEXT_MENU_COPY_TO": "Copy to...", "CONTEXT_MENU_COPY_TO": "Copy to...",
"CONTEXT_MENU_ROTATE": "Rotate", "CONTEXT_MENU_ROTATE": "Rotate",
@@ -913,6 +956,7 @@ _UI_TEXTS = {
"SEARCH": "Buscar", "SEARCH": "Buscar",
"SELECT": "Seleccionar", "SELECT": "Seleccionar",
"ERROR": "Error", "ERROR": "Error",
"FILE_NOT_FOUND": "Archivo no encontrado",
"WARNING": "Advertencia", "WARNING": "Advertencia",
"INFO": "Información", "INFO": "Información",
"LOAD": "Cargar", "LOAD": "Cargar",
@@ -1033,30 +1077,55 @@ _UI_TEXTS = {
"MENU_DUPLICATES": "Duplicados", "MENU_DUPLICATES": "Duplicados",
"MENU_DETECT_CURRENT_SEARCH": "Detectar en búsqueda actual", "MENU_DETECT_CURRENT_SEARCH": "Detectar en búsqueda actual",
"MENU_DETECT_ALL": "Detectar todos", "MENU_DETECT_ALL": "Detectar todos",
"MENU_FORCE_FULL_ALL_ANALYSIS": "Forzar análisis completo de todo",
"MENU_FORCE_FULL_ANALYSIS": "Forzar análisis completo", "MENU_FORCE_FULL_ANALYSIS": "Forzar análisis completo",
"MENU_REVIEW_IGNORED": "Revisar ignorados", "MENU_REVIEW_IGNORED": "Revisar ignorados",
"MENU_CLEAN_UP_HASHES": "Limpiar", "MENU_CLEAN_UP_HASHES": "Limpiar",
"MENU_REPAIR_DATABASE": "Reparar índice",
"MENU_CLEAR_EXCEPTIONS": "Limpiar parejas ignoradas",
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirmar Limpieza de Ignorados",
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "¿Seguro que quieres borrar todas las parejas "
"de duplicados ignoradas? Se volverán a detectar en el próximo escaneo.",
"REPAIRING_DATABASE": "Reparando índice de duplicados...",
"MENU_CLEAR_HASHES": "Limpiar hashes ({} ítems, {:.1f} MB en disco)", "MENU_CLEAR_HASHES": "Limpiar hashes ({} ítems, {:.1f} MB en disco)",
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpieza de Hashes", "CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpieza de Hashes",
"CONFIRM_CLEAR_HASHES_TEXT": "¿Seguro que quieres eliminar permanentemente toda la base de datos de hashes?", "CONFIRM_CLEAR_HASHES_TEXT": "¿Seguro que quieres eliminar permanentemente "
"CONFIRM_CLEAR_HASHES_INFO": "Esto eliminará todos los hashes de imágenes calculados. Se recalcularán a medida que detectes duplicados, lo que puede ser lento. Esta acción no se puede deshacer.", "toda la base de datos de hashes?",
"CONFIRM_CLEAR_HASHES_INFO": "Esto eliminará todos los hashes de imágenes "
"calculados. Se recalcularán a medida que detectes duplicados, lo que puede "
"ser lento. Esta acción no se puede deshacer.",
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:", "SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona el método para la detección de duplicados.", "SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona el método para la detección "
"de duplicados.",
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing", "METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
"METHOD_RESNET": "ResNet (Basado en IA)", "METHOD_RESNET": "ResNet (Basado en IA)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar duplicados", "SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
"duplicados",
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista blanca (carpetas a incluir):", "SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista blanca (carpetas a incluir):",
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de carpetas separadas por comas para escanear al usar 'Detectar todos'.", "SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de carpetas separadas por comas "
"para escanear al usar 'Detectar todos'.",
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (carpetas a excluir):", "SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (carpetas a excluir):",
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de carpetas separadas por comas para ignorar durante escaneos de 'Detectar todos'.", "SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de carpetas separadas por comas "
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar todos': {}", "para ignorar durante escaneos de 'Detectar todos'.",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "La tecla Supr envía a la papelera por defecto", "SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar "
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Si está marcada, al pulsar la tecla Supr se moverán los archivos a la papelera. Si no, se eliminarán permanentemente.", "todos': {}",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Muestra un diálogo de confirmación antes de mover una imagen duplicada a la papelera.", "SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "La tecla Supr envía a la papelera "
"por defecto",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Si está marcada, al pulsar la "
"tecla Supr se moverán los archivos a la papelera. Si no, se eliminarán "
"permanentemente.",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Muestra un diálogo de "
"confirmación antes de mover una imagen duplicada a la papelera.",
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitud:", "SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitud:",
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece el umbral de similitud (50-100%). Valores más altos significan que las imágenes deben ser más parecidas para considerarse duplicadas.", "SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece el umbral de similitud "
"SETTINGS_DUPLICATE_MISSING_LIBS": "La librería 'imagehash' es necesaria para la detección de duplicados pero no se ha encontrado. Esta función está desactivada.", "(50-100%). Valores más altos significan que las imágenes deben ser más "
"parecidas para considerarse duplicadas.",
"SETTINGS_DUPLICATE_MISSING_LIBS": "La librería 'imagehash' es necesaria "
"para la detección de duplicados pero no se ha encontrado. Esta función "
"está desactivada.",
"MENU_DETECT_DUPLICATES": "Detectar Duplicados", "MENU_DETECT_DUPLICATES": "Detectar Duplicados",
"DUPLICATE_WHITELIST_EMPTY": "La lista blanca está vacía. Por favor, "
"configúrela en Opciones.",
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados", "DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
"DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.", "DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.",
"DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.", "DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.",
@@ -1081,6 +1150,7 @@ _UI_TEXTS = {
"VIEWER_MENU_LINK_PANES": "Vincular Paneles", "VIEWER_MENU_LINK_PANES": "Vincular Paneles",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"DUPLICATE_LIST_HEADER": "Parejas Duplicadas", "DUPLICATE_LIST_HEADER": "Parejas Duplicadas",
"IGNORED_DATE": "Fecha Ignorado",
"SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
@@ -1142,6 +1212,11 @@ _UI_TEXTS = {
"alrededor de los lugares.", "alrededor de los lugares.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nombres de lugares " "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nombres de lugares "
"usados recientemente para recordar.", "usados recientemente para recordar.",
"SETTINGS_PATH_NOT_FOUND_WARNING": "Advertencia: La ruta no existe o "
"no es un directorio: {}",
"SETTINGS_USE_LAST_NAME_LABEL": "Usar último nombre por defecto",
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Rellena automáticamente la ventana de "
"asignación con el último nombre utilizado.",
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:", "SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):", "SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Color de fondo de miniaturas:", "SETTINGS_THUMBS_BG_COLOR_LABEL": "Color de fondo de miniaturas:",
@@ -1282,6 +1357,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "El archivo '{}' ya existe.", "RENAME_ERROR_EXISTS": "El archivo '{}' ya existe.",
"FILE_RENAMED": "Archivo renombrado a {}", "FILE_RENAMED": "Archivo renombrado a {}",
"ERROR_RENAME": "No se pudo renombrar el archivo: {}", "ERROR_RENAME": "No se pudo renombrar el archivo: {}",
"ERROR_JPEG_METADATA_LIMIT": "Límite de metadatos excedido para '{}'. Este "
"archivo JPEG ya tiene demasiados metadatos (XMP) para guardar más.",
"MAIN_DOCK_TITLE": "Panel principal", "MAIN_DOCK_TITLE": "Panel principal",
"LAYOUTS_TAB": "Diseños", "LAYOUTS_TAB": "Diseños",
"LAYOUTS_TABLE_HEADER": ["Nombre", "Última Modificación"], "LAYOUTS_TABLE_HEADER": ["Nombre", "Última Modificación"],
@@ -1318,6 +1395,9 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 TODAS LAS ETIQUETAS", "TAG_ALL_TAGS": "📂 TODAS LAS ETIQUETAS",
"TAG_NEW_TAG_TITLE": "Nueva Etiqueta", "TAG_NEW_TAG_TITLE": "Nueva Etiqueta",
"SEARCH_BY_TAG": "Buscar por esta etiqueta", "SEARCH_BY_TAG": "Buscar por esta etiqueta",
"TAG_ADD_TOOLTIP": "Crear una nueva etiqueta",
"TAG_REFRESH_TOOLTIP": "Refrescar etiquetas disponibles desde el base de datos "
"de Baloo",
"TAG_NEW_TAG_TEXT": "Introduce el nombre de la etiqueta (usa / para " "TAG_NEW_TAG_TEXT": "Introduce el nombre de la etiqueta (usa / para "
"jerarquía):", "jerarquía):",
"SEARCH_ADD_AND": "Añadir AND esta etiqueta a la búsqueda", "SEARCH_ADD_AND": "Añadir AND esta etiqueta a la búsqueda",
@@ -1353,6 +1433,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"], "PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"],
"PROPERTIES_ADD_ATTR": "Añadir Atributo", "PROPERTIES_ADD_ATTR": "Añadir Atributo",
"PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):", "PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):",
"PROPERTIES_DELETE_ALL": "Borrar Todo",
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
"PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}", "PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}",
@@ -1405,7 +1486,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_OPEN": "Abrir", "CONTEXT_MENU_OPEN": "Abrir",
"CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir y buscar en ubicación", "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir y buscar en ubicación",
"CONTEXT_MENU_OPEN_DEFAULT_APP": "Abrir ubicación con aplicación por defecto", "CONTEXT_MENU_OPEN_DEFAULT_APP": "Abrir ubicación con aplicación por defecto",
"CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa", "CONTEXT_MENU_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa",
"CONTEXT_MENU_MOVE_TO": "Mover a...", "CONTEXT_MENU_MOVE_TO": "Mover a...",
"CONTEXT_MENU_COPY_TO": "Copiar a...", "CONTEXT_MENU_COPY_TO": "Copiar a...",
"CONTEXT_MENU_ROTATE": "Girar", "CONTEXT_MENU_ROTATE": "Girar",
@@ -1440,6 +1521,7 @@ _UI_TEXTS = {
"SEARCH": "Buscar", "SEARCH": "Buscar",
"SELECT": "Seleccionar", "SELECT": "Seleccionar",
"ERROR": "Erro", "ERROR": "Erro",
"FILE_NOT_FOUND": "Ficheiro non atopado",
"WARNING": "Advertencia", "WARNING": "Advertencia",
"INFO": "Información", "INFO": "Información",
"LOAD": "Cargar", "LOAD": "Cargar",
@@ -1561,30 +1643,54 @@ _UI_TEXTS = {
"MENU_DUPLICATES": "Duplicados", "MENU_DUPLICATES": "Duplicados",
"MENU_DETECT_CURRENT_SEARCH": "Detectar na busca actual", "MENU_DETECT_CURRENT_SEARCH": "Detectar na busca actual",
"MENU_DETECT_ALL": "Detectar todos", "MENU_DETECT_ALL": "Detectar todos",
"MENU_FORCE_FULL_ALL_ANALYSIS": "Forzar análise completa de todo",
"MENU_FORCE_FULL_ANALYSIS": "Forzar análise completa", "MENU_FORCE_FULL_ANALYSIS": "Forzar análise completa",
"MENU_REVIEW_IGNORED": "Revisar ignorados", "MENU_REVIEW_IGNORED": "Revisar ignorados",
"MENU_CLEAN_UP_HASHES": "Limpar", "MENU_CLEAN_UP_HASHES": "Limpar",
"MENU_REPAIR_DATABASE": "Reparar índice",
"MENU_CLEAR_EXCEPTIONS": "Limpar parellas ignoradas",
"CONFIRM_CLEAR_EXCEPTIONS_TITLE": "Confirmar Limpeza de Ignorados",
"CONFIRM_CLEAR_EXCEPTIONS_TEXT": "Seguro que queres borrar todas as parellas "
"de duplicados ignoradas? Volveranse detectar no vindeiro escaneo.",
"REPAIRING_DATABASE": "Reparando índice de duplicados...",
"MENU_CLEAR_HASHES": "Limpar hashes ({} elementos, {:.1f} MB en disco)", "MENU_CLEAR_HASHES": "Limpar hashes ({} elementos, {:.1f} MB en disco)",
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpeza de Hashes", "CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpeza de Hashes",
"CONFIRM_CLEAR_HASHES_TEXT": "Seguro que queres eliminar permanentemente toda a base de datos de hashes?", "CONFIRM_CLEAR_HASHES_TEXT": "Seguro que queres eliminar permanentemente toda "
"CONFIRM_CLEAR_HASHES_INFO": "Isto eliminará todos os hashes de imaxes calculados. Rexeneraranse a medida que detectes duplicados, o que pode ser lento. Esta acción non se pode deshacer.", "a base de datos de hashes?",
"CONFIRM_CLEAR_HASHES_INFO": "Isto eliminará todos os hashes de imaxes "
"calculados. Rexeneraranse a medida que detectes duplicados, o que pode ser "
"lento. Esta acción non se pode deshacer.",
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:", "SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona o método para a detección de duplicados.", "SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona o método para a detección "
"de duplicados.",
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing", "METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
"METHOD_RESNET": "ResNet (Baseado en IA)", "METHOD_RESNET": "ResNet (Baseado en IA)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar duplicados", "SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
"duplicados",
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista branca (cartafoles a incluír):", "SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista branca (cartafoles a incluír):",
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de cartafoles separadas por comas para escanear ao usar 'Detectar todos'.", "SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de cartafoles separadas por "
"comas para escanear ao usar 'Detectar todos'.",
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (cartafoles a excluír):", "SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (cartafoles a excluír):",
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de cartafoles separadas por comas para ignorar durante escaneos de 'Detectar todos'.", "SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de cartafoles separadas por "
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar todos': {}", "comas para ignorar durante escaneos de 'Detectar todos'.",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "A tecla Supr envía á papeleira por defecto", "SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar "
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Se está marcada, ao premer a tecla Supr moveranse os ficheiros á papeleira. Se non, eliminaranse permanentemente.", "todos': {}",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Amosa un diálogo de confirmación antes de mover unha imaxe duplicada á papeleira.", "SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "A tecla Supr envía á papeleira por "
"defecto",
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Se está marcada, ao premer a "
"tecla Supr moveranse os ficheiros á papeleira. Se non, eliminaranse "
"permanentemente.",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Amosa un diálogo de confirmación "
"antes de mover unha imaxe duplicada á papeleira.",
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitude:", "SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitude:",
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece o umbral de similitude (50-100%). Valores máis altos significan que as imaxes deben ser máis parecidas para considerarse duplicadas.", "SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece o umbral de similitude "
"SETTINGS_DUPLICATE_MISSING_LIBS": "A librería 'imagehash' é necesaria para a detección de duplicados pero non se atopou. Esta función está desactivada.", "(50-100%). Valores máis altos significan que as imaxes deben ser máis "
"parecidas para considerarse duplicadas.",
"SETTINGS_DUPLICATE_MISSING_LIBS": "A librería 'imagehash' é necesaria para a "
"detección de duplicados pero non se atopou. Esta función está desactivada.",
"MENU_DETECT_DUPLICATES": "Detectar Duplicados", "MENU_DETECT_DUPLICATES": "Detectar Duplicados",
"DUPLICATE_WHITELIST_EMPTY": "A lista branca está baleira. Por favor, "
"configúrea en Opcións.",
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados", "DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
"DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.", "DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.",
"DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.", "DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.",
@@ -1609,6 +1715,7 @@ _UI_TEXTS = {
"VIEWER_MENU_LINK_PANES": "Vincular Paneis", "VIEWER_MENU_LINK_PANES": "Vincular Paneis",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación", "DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"DUPLICATE_LIST_HEADER": "Parellas Duplicadas", "DUPLICATE_LIST_HEADER": "Parellas Duplicadas",
"IGNORED_DATE": "Data Ignorado",
"SETTINGS_GROUP_SCANNER": "Escáner", "SETTINGS_GROUP_SCANNER": "Escáner",
"SETTINGS_GROUP_AREAS": "Áreas", "SETTINGS_GROUP_AREAS": "Áreas",
"SETTINGS_GROUP_THUMBNAILS": "Miniaturas", "SETTINGS_GROUP_THUMBNAILS": "Miniaturas",
@@ -1670,6 +1777,11 @@ _UI_TEXTS = {
"arredor dos lugares.", "arredor dos lugares.",
"SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nomes de lugares " "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nomes de lugares "
"usados recentemente para lembrar.", "usados recentemente para lembrar.",
"SETTINGS_PATH_NOT_FOUND_WARNING": "Advertencia: A ruta non existe ou "
"non é un directorio: {}",
"SETTINGS_USE_LAST_NAME_LABEL": "Usar o último nome por defecto",
"SETTINGS_USE_LAST_NAME_TOOLTIP": "Rechea automáticamente a ventá de "
"asignación có último nome utilizado.",
"SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:", "SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máximo historial de caras:",
"SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):", "SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):",
"SETTINGS_THUMBS_BG_COLOR_LABEL": "Cor de fondo de miniaturas:", "SETTINGS_THUMBS_BG_COLOR_LABEL": "Cor de fondo de miniaturas:",
@@ -1809,6 +1921,8 @@ _UI_TEXTS = {
"RENAME_ERROR_EXISTS": "O ficheiro '{}' xa existe.", "RENAME_ERROR_EXISTS": "O ficheiro '{}' xa existe.",
"FILE_RENAMED": "Ficheiro renomeado a {}", "FILE_RENAMED": "Ficheiro renomeado a {}",
"ERROR_RENAME": "Non se puido renomear o ficheiro: {}", "ERROR_RENAME": "Non se puido renomear o ficheiro: {}",
"ERROR_JPEG_METADATA_LIMIT": "Límite de metadatos excedido para '{}'. Este "
"ficheiro JPEG xa ten demasiados metadatos (XMP) para gardar máis.",
"MAIN_DOCK_TITLE": "Panel principal", "MAIN_DOCK_TITLE": "Panel principal",
"LAYOUTS_TAB": "Deseños", "LAYOUTS_TAB": "Deseños",
"LAYOUTS_TABLE_HEADER": ["Nome", "Última Modificación"], "LAYOUTS_TABLE_HEADER": ["Nome", "Última Modificación"],
@@ -1845,6 +1959,9 @@ _UI_TEXTS = {
"TAG_ALL_TAGS": "📂 TÓDALAS ETIQUETAS", "TAG_ALL_TAGS": "📂 TÓDALAS ETIQUETAS",
"TAG_NEW_TAG_TITLE": "Nova Etiqueta", "TAG_NEW_TAG_TITLE": "Nova Etiqueta",
"SEARCH_BY_TAG": "Buscar por esta etiqueta", "SEARCH_BY_TAG": "Buscar por esta etiqueta",
"TAG_ADD_TOOLTIP": "Crear unha nova etiqueta",
"TAG_REFRESH_TOOLTIP": "Refrescar etiquetas dispoñibles dende a base de datos "
"de Baloo",
"TAG_NEW_TAG_TEXT": "Introduce o nome da etiqueta (usa / para " "TAG_NEW_TAG_TEXT": "Introduce o nome da etiqueta (usa / para "
"xerarquía):", "xerarquía):",
"SEARCH_ADD_AND": "Engadir AND esta etiqueta á busca", "SEARCH_ADD_AND": "Engadir AND esta etiqueta á busca",
@@ -1880,6 +1997,7 @@ _UI_TEXTS = {
"PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"], "PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"],
"PROPERTIES_ADD_ATTR": "Engadir Atributo", "PROPERTIES_ADD_ATTR": "Engadir Atributo",
"PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):", "PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):",
"PROPERTIES_DELETE_ALL": "Borrar Todo",
"PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:",
"PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}", "PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}",
"PROPERTIES_ERROR_ADD_ATTR": "Fallo ao engadir xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Fallo ao engadir xattr: {}",
@@ -1943,7 +2061,7 @@ _UI_TEXTS = {
"CONTEXT_MENU_COPY_FILE": "Copiar URL do Ficheiro", "CONTEXT_MENU_COPY_FILE": "Copiar URL do Ficheiro",
"CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio", "CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio",
"CONTEXT_MENU_PROPERTIES": "Propiedades", "CONTEXT_MENU_PROPERTIES": "Propiedades",
"CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa", "CONTEXT_MENU_FULLSCREEN_VIEWER": "Abrir con Visor a Pantalla Completa",
"CONTEXT_MENU_NO_APPS_FOUND": "Non se atoparon aplicacións", "CONTEXT_MENU_NO_APPS_FOUND": "Non se atoparon aplicacións",
"CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura", "CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura",
"CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións", "CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións",
@@ -1968,6 +2086,7 @@ _UI_TEXTS = {
# Determine which language to use for UI strings # Determine which language to use for UI strings
def _get_current_language(): def _get_current_language():
"""Determines the language to use for UI strings based on environment."""
lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE) lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE)
if lang == "system": if lang == "system":

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,14 @@
"""
File System Watcher Module for Bagheera Image Viewer.
This module provides functionality to monitor file system changes in real-time
using the watchdog library. It notifies the application about new, deleted, or
modified image files within watched directories, handling debouncing to ensure
stability during rapid file operations.
Classes:
FileSystemWatcher: Coordinates file system monitoring and emits Qt signals.
"""
import os import os
try: try:
from watchdog.observers import Observer from watchdog.observers import Observer
@@ -14,20 +25,32 @@ class FileSystemWatcher(QObject):
Monitors file system events (created, deleted, modified) for specified directories. Monitors file system events (created, deleted, modified) for specified directories.
Emits signals to notify the main application thread of changes. Emits signals to notify the main application thread of changes.
""" """
# Signals emitted to the rest of the application
# ---------------------------------------------
file_created = Signal(str) file_created = Signal(str)
file_deleted = Signal(str) file_deleted = Signal(str)
file_modified = Signal(str) file_modified = Signal(str)
_file_modified_from_handler = Signal(str) # Internal signal from handler thread _file_modified_from_handler = Signal(str) # Internal signal from handler thread
file_moved = Signal(str, str) file_moved = Signal(str, str)
monitoring_status_changed = Signal(bool) # Nuevo: Señal para el estado de monitoreo monitoring_status_changed = Signal(bool) # New: Signal for monitoring status
directory_moved = Signal(str, str) directory_moved = Signal(str, str)
directory_modified = Signal(str) # For changes that might not be specific files directory_modified = Signal(str) # For changes that might not be specific files
_modified_events_queue = {} # {path: QTimer} _modified_events_queue = {} # {path: QTimer}
"""Queue to manage debouncing of modification events."""
def __init__(self, parent=None): def __init__(self, parent=None):
"""
Initializes the FileSystemWatcher.
Args:
parent (QObject, optional): The parent object. Defaults to None.
"""
super().__init__(parent) super().__init__(parent)
self._watched_directories = set() self._watched_directories = set()
self._debounce_interval = 500 # milliseconds
if HAVE_WATCHDOG: if HAVE_WATCHDOG:
self._observer = Observer() self._observer = Observer()
@@ -36,16 +59,21 @@ class FileSystemWatcher(QObject):
else: else:
self._observer = None # Keep observer as None if watchdog is not available self._observer = None # Keep observer as None if watchdog is not available
# Debounce timer for modified events to avoid multiple signals for a single save
self._debounce_interval = 500 # milliseconds
# Connect the internal signal to the debouncing slot # Connect the internal signal to the debouncing slot
if HAVE_WATCHDOG: if HAVE_WATCHDOG:
self._file_modified_from_handler.connect(self._on_file_modified_debounced) self._file_modified_from_handler.connect(self._on_file_modified_debounced)
def _on_file_modified_debounced(self, path): def _on_file_modified_debounced(self, path):
"""Slot to handle modified events from the watchdog thread, debounced in the """
main thread.""" Slot to handle modified events from the watchdog thread.
Implements a debouncing mechanism: if multiple modification events
arrive for the same path within the interval, previous timers are
reset to avoid redundant UI updates or heavy disk operations.
Args:
path (str): The path of the modified file.
"""
# Debounce timer for modified events to avoid multiple signals for a single save # Debounce timer for modified events to avoid multiple signals for a single save
if path in self._modified_events_queue: if path in self._modified_events_queue:
self._modified_events_queue[path].stop() self._modified_events_queue[path].stop()
@@ -59,7 +87,12 @@ class FileSystemWatcher(QObject):
self._modified_events_queue[path].start() self._modified_events_queue[path].start()
def _emit_modified_after_debounce(self, path): def _emit_modified_after_debounce(self, path):
"""Emits the file_modified signal after the debounce period.""" """
Emits the file_modified signal after the debounce period.
Args:
path (str): The path of the modified file.
"""
self.file_modified.emit(path) self.file_modified.emit(path)
if path in self._modified_events_queue: if path in self._modified_events_queue:
# Safely delete the QTimer object when done # Safely delete the QTimer object when done
@@ -67,7 +100,16 @@ class FileSystemWatcher(QObject):
del self._modified_events_queue[path] del self._modified_events_queue[path]
def add_path(self, path): def add_path(self, path):
"""Adds a directory to be monitored.""" """
Adds a directory to be monitored.
This method ensures that redundant watches are avoided by checking if
the path is already covered by an existing watch or if it should
consolidate multiple sub-watches into a single parent watch.
Args:
path (str): The directory path to monitor.
"""
if not HAVE_WATCHDOG or self._observer is None: if not HAVE_WATCHDOG or self._observer is None:
return return
@@ -111,7 +153,12 @@ class FileSystemWatcher(QObject):
self.monitoring_status_changed.emit(True) self.monitoring_status_changed.emit(True)
def remove_path(self, path): def remove_path(self, path):
"""Removes a directory from monitoring.""" """
Removes a directory from monitoring.
Args:
path (str): The directory path to stop monitoring.
"""
if not HAVE_WATCHDOG or self._observer is None: if not HAVE_WATCHDOG or self._observer is None:
return return
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path))) abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
@@ -138,7 +185,9 @@ class FileSystemWatcher(QObject):
self.monitoring_status_changed.emit(False) self.monitoring_status_changed.emit(False)
def stop(self): def stop(self):
"""Stops the file system observer.""" """
Stops the file system observer and cleans up active timers.
"""
if HAVE_WATCHDOG and self._observer: if HAVE_WATCHDOG and self._observer:
self._observer.stop() self._observer.stop()
self._observer.join() self._observer.join()
@@ -150,14 +199,24 @@ class FileSystemWatcher(QObject):
if HAVE_WATCHDOG: if HAVE_WATCHDOG:
class _Handler(FileSystemEventHandler): class _Handler(FileSystemEventHandler):
"""
Custom event handler for watchdog events.
Translates low-level file system events into high-level application
signals, filtering for supported image types.
"""
# Signal to communicate to main thread # Signal to communicate to main thread
file_modified_from_thread = Signal(str) file_modified_from_thread = Signal(str)
"""Custom event handler for watchdog events."""
def __init__(self, watcher): def __init__(self, watcher):
"""
Initializes the handler with a reference to the main watcher.
"""
super().__init__() super().__init__()
self.watcher = watcher self.watcher = watcher
def on_created(self, event): def on_created(self, event):
"""Called when a file or directory is created."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -165,6 +224,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_created.emit(event.src_path) self.watcher.file_created.emit(event.src_path)
def on_deleted(self, event): def on_deleted(self, event):
"""Called when a file or directory is deleted."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -172,6 +232,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_deleted.emit(event.src_path) self.watcher.file_deleted.emit(event.src_path)
def on_moved(self, event): def on_moved(self, event):
"""Called when a file or directory is moved or renamed."""
if event.is_directory: if event.is_directory:
self.watcher.directory_moved.emit(event.src_path, event.dest_path) self.watcher.directory_moved.emit(event.src_path, event.dest_path)
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
@@ -180,6 +241,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_moved.emit(event.src_path, event.dest_path) self.watcher.file_moved.emit(event.src_path, event.dest_path)
def on_closed(self, event): def on_closed(self, event):
"""Called when a file is closed."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -187,6 +249,7 @@ class FileSystemWatcher(QObject):
self.watcher.file_modified.emit(event.src_path) self.watcher.file_modified.emit(event.src_path)
def on_modified(self, event): def on_modified(self, event):
"""Called when a file or directory is modified."""
if event.is_directory: if event.is_directory:
self.watcher.directory_modified.emit(event.src_path) self.watcher.directory_modified.emit(event.src_path)
return return
@@ -194,9 +257,21 @@ class FileSystemWatcher(QObject):
self.watcher._file_modified_from_handler.emit(event.src_path) self.watcher._file_modified_from_handler.emit(event.src_path)
def _emit_modified(self, path): def _emit_modified(self, path):
"""
Internal helper to emit the modified signal.
Args:
path (str): The modified path.
"""
self.watcher.file_modified.emit(path) self.watcher.file_modified.emit(path)
if path in self.watcher._modified_events_queue: if path in self.watcher._modified_events_queue:
del self.watcher._modified_events_queue[path] del self.watcher._modified_events_queue[path]
def _is_image_file(self, path): def _is_image_file(self, path):
"""
Checks if a given path has a supported image extension.
Args:
path (str): The file path to check.
"""
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS

View File

@@ -42,6 +42,7 @@ class ImagePreloader(QThread):
def __init__(self): def __init__(self):
"""Initializes the preloader thread.""" """Initializes the preloader thread."""
super().__init__() super().__init__()
self.setObjectName("ImagePreloaderThread")
self.path = None self.path = None
self.index = -1 self.index = -1
self.mutex = QMutex() self.mutex = QMutex()
@@ -344,7 +345,7 @@ class ImageController(QObject):
faces_to_save.append(face_copy) faces_to_save.append(face_copy)
XmpManager.save_faces(path, faces_to_save) return XmpManager.save_faces(path, faces_to_save)
def add_face(self, name, x, y, w, h, region_type="Face"): def add_face(self, name, x, y, w, h, region_type="Face"):
"""Adds a new face. The full tag path should be passed as 'name'.""" """Adds a new face. The full tag path should be passed as 'name'."""
@@ -389,8 +390,8 @@ class ImageController(QObject):
self.metadata_changed.emit(current_path, self.metadata_changed.emit(current_path,
{'tags': new_tags_list, {'tags': new_tags_list,
'rating': self._current_rating}) 'rating': self._current_rating})
except IOError as e: except Exception:
print(f"Error setting tags for {current_path}: {e}") raise
def set_rating(self, new_rating): def set_rating(self, new_rating):
current_path = self.get_current_path() current_path = self.get_current_path()
@@ -688,39 +689,36 @@ class ImageController(QObject):
if self.pixmap_original.isNull(): if self.pixmap_original.isNull():
return QPixmap() return QPixmap()
# Ensure pixmap_original is a valid, independent copy before transforming # Start with an identity transform
temp_pixmap = QPixmap(self.pixmap_original) transform = QTransform()
if temp_pixmap.isNull():
return QPixmap()
# Use rotated() which returns a new QTransform, potentially safer # Apply rotation
transform = QTransform() # Initialize to identity transform
if self.rotation != 0: if self.rotation != 0:
transform = QTransform().rotated(float(self.rotation)) transform.rotate(float(self.rotation))
transformed_pixmap = temp_pixmap.transformed( # Apply flips
if self.flip_h:
transform.scale(-1, 1)
if self.flip_v:
transform.scale(1, -1)
# Apply the cumulative transform to the original pixmap
transformed_pixmap = self.pixmap_original.transformed(
transform, Qt.TransformationMode.SmoothTransformation) transform, Qt.TransformationMode.SmoothTransformation)
# Calculate new size, explicitly converting QSizeF to QSize # Apply scaling (zoom) separately after rotation and flips,
# as scaling should be based on the *transformed* dimensions.
# This is important: if you scale before rotation, the scaling
# factors might be applied to the wrong axes.
if self.zoom_factor != 1.0:
new_size_f = transformed_pixmap.size() * self.zoom_factor new_size_f = transformed_pixmap.size() * self.zoom_factor
new_size = QSize(int(new_size_f.width()), int(new_size_f.height())) new_size = QSize(int(new_size_f.width()), int(new_size_f.height()))
scaled_pixmap = transformed_pixmap.scaled( scaled_pixmap = transformed_pixmap.scaled(
new_size, Qt.AspectRatioMode.KeepAspectRatio, new_size, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation) Qt.TransformationMode.SmoothTransformation)
if self.flip_h:
t_flip_h = QTransform()
t_flip_h.scale(-1, 1)
scaled_pixmap = scaled_pixmap.transformed(
t_flip_h, Qt.TransformationMode.SmoothTransformation)
if self.flip_v:
t_flip_v = QTransform()
t_flip_v.scale(1, -1)
scaled_pixmap = scaled_pixmap.transformed(
t_flip_v, Qt.TransformationMode.SmoothTransformation)
return scaled_pixmap return scaled_pixmap
else:
return transformed_pixmap
def rotate(self, angle): def rotate(self, angle):
""" """

View File

@@ -32,13 +32,13 @@ from PySide6.QtCore import (
QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition, QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, QSemaphore, QWaitCondition,
QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile QByteArray, QBuffer, QIODevice, Qt, QTimer, QRunnable, QThreadPool, QFile
) )
from PySide6.QtGui import QImage, QImageReader, QImageIOHandler from PySide6.QtGui import QImage, QImageReader, QImageIOHandler, QIcon
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from constants import ( from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES, CONFIG_DIR, APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES,
MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES, HAVE_BAGHEERASEARCH_LIB, APP_DATA_DIR, MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES,
IMAGE_EXTENSIONS, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
UITexts UITexts
) )
@@ -134,13 +134,11 @@ class ScannerWorker(QRunnable):
sizes_to_check = self.target_sizes if self.target_sizes is not None \ sizes_to_check = self.target_sizes if self.target_sizes is not None \
else SCANNER_GENERATE_SIZES else SCANNER_GENERATE_SIZES
if self._is_cancelled:
if self.semaphore:
self.semaphore.release()
return
fd = None fd = None
try: try:
if self._is_cancelled:
return
# Optimize: Open file once to reuse FD for stat and xattrs # Optimize: Open file once to reuse FD for stat and xattrs
fd = os.open(self.path, os.O_RDONLY) fd = os.open(self.path, os.O_RDONLY)
stat_res = os.fstat(fd) stat_res = os.fstat(fd)
@@ -198,8 +196,11 @@ class ScannerWorker(QRunnable):
tags, rating = res_meta.tags, res_meta.rating tags, rating = res_meta.tags, res_meta.rating
self.result = (self.path, smallest_thumb_for_signal, self.result = (self.path, smallest_thumb_for_signal,
curr_mtime, tags, rating, curr_inode, curr_dev) curr_mtime, tags, rating, curr_inode, curr_dev)
except (FileNotFoundError, PermissionError) as e:
logger.debug(f"Skipping {self.path} due to access issue: {e}")
self.result = None
except Exception as e: except Exception as e:
logger.error(f"Error processing image {self.path}: {e}") logger.warning(f"Unexpected error processing image {self.path}: {e}")
self.result = None self.result = None
finally: finally:
if fd is not None: if fd is not None:
@@ -267,7 +268,7 @@ def generate_thumbnail(path, size, fd=None):
# better quality for upscaling. # better quality for upscaling.
return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
except Exception as e: except Exception as e:
logger.error(f"Error generating thumbnail for {path}: {e}") logger.debug(f"Could not generate thumbnail for {path}: {e}")
return None return None
@@ -285,6 +286,7 @@ class CacheWriter(QThread):
self._condition_new_data = QWaitCondition() self._condition_new_data = QWaitCondition()
self._condition_space_available = QWaitCondition() self._condition_space_available = QWaitCondition()
# Soft limit for blocking producers (background threads) # Soft limit for blocking producers (background threads)
self.setObjectName("CacheWriterThread") # Add this line
self._max_size = 50 self._max_size = 50
self._running = True self._running = True
@@ -334,6 +336,7 @@ class CacheWriter(QThread):
self._running = False self._running = False
# Do not clear the queue here; let the run loop drain it to prevent data loss. # Do not clear the queue here; let the run loop drain it to prevent data loss.
self._condition_new_data.wakeAll() self._condition_new_data.wakeAll()
logger.debug(f"{self.objectName()} stop requested, waking all.")
self._condition_space_available.wakeAll() self._condition_space_available.wakeAll()
self._mutex.unlock() self._mutex.unlock()
@@ -380,6 +383,7 @@ class CacheWriter(QThread):
self.cache._batch_write_to_lmdb(batch) self.cache._batch_write_to_lmdb(batch)
except Exception as e: except Exception as e:
logger.error(f"CacheWriter batch write error: {e}") logger.error(f"CacheWriter batch write error: {e}")
logger.debug(f"{self.objectName()} run method exiting.")
class CacheLoader(QThread): class CacheLoader(QThread):
@@ -522,15 +526,24 @@ class ThumbnailCache(QObject):
self._db_lock = QMutex() # Lock specifically for _db_handles access self._db_lock = QMutex() # Lock specifically for _db_handles access
self._db_handles = {} # Cache for LMDB database handles (dbi) self._db_handles = {} # Cache for LMDB database handles (dbi)
self._cancel_loading = False self._cancel_loading = False
self._broken_cache = {} # (dev, inode, size) -> (mtime, error_msg)
self._cache_bytes_size = 0 self._cache_bytes_size = 0
self._cache_writer = None self._cache_writer = None
self._cache_loader = None self._cache_loader = None
# Pre-generate broken images for standard tiers in the main thread
self._broken_images = {}
for size in THUMBNAIL_SIZES:
icon = QIcon.fromTheme("image-missing",
QIcon.fromTheme("broken-image",
QIcon.fromTheme("dialog-error")))
self._broken_images[size] = icon.pixmap(size, size).toImage()
self.lmdb_open() self.lmdb_open()
def lmdb_open(self): def lmdb_open(self):
# Initialize LMDB environment # Initialize LMDB environment
cache_dir = Path(CONFIG_DIR) cache_dir = Path(APP_DATA_DIR)
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
try: try:
@@ -732,12 +745,28 @@ class ThumbnailCache(QObject):
def _get_tier_for_size(self, requested_size): def _get_tier_for_size(self, requested_size):
"""Determines the ideal thumbnail tier based on the requested size.""" """Determines the ideal thumbnail tier based on the requested size."""
if requested_size < 192: if requested_size <= 128:
return 128 return 128
if requested_size < 320: if requested_size <= 256:
return 256 return 256
return 512 return 512
def mark_broken(self, path, size, mtime, inode, dev_id, error_msg):
"""Marks a thumbnail load as failed with a message."""
key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size)
with self._write_lock():
self._broken_cache[key] = (mtime, error_msg)
def get_broken_info(self, path, size, mtime, inode, dev_id):
"""Returns the error message if a thumbnail is known to have failed, else
None."""
key = (dev_id, struct.pack('Q', inode) if inode is not None else None, size)
with self._read_lock():
info = self._broken_cache.get(key)
if info and info[0] == mtime:
return info[1]
return None
def _resolve_file_identity(self, path, curr_mtime, inode, device_id): def _resolve_file_identity(self, path, curr_mtime, inode, device_id):
"""Helper to resolve file mtime, device, and inode.""" """Helper to resolve file mtime, device, and inode."""
mtime = curr_mtime mtime = curr_mtime
@@ -858,6 +887,12 @@ class ThumbnailCache(QObject):
if mtime is None: if mtime is None:
return EMPTY_THUMBNAIL return EMPTY_THUMBNAIL
# Check if known to be broken
broken_msg = self.get_broken_info(path, target_tier, mtime, inode, device_id)
if broken_msg:
return ThumbnailResult(
self._broken_images.get(target_tier), mtime, target_tier)
best_img, best_mtime, best_tier = None, 0, 0 best_img, best_mtime, best_tier = None, 0, 0
with self._read_lock(): with self._read_lock():
@@ -1328,6 +1363,7 @@ class CacheCleaner(QThread):
def stop(self): def stop(self):
"""Signals the thread to stop.""" """Signals the thread to stop."""
self._is_running = False self._is_running = False
self.wait()
def run(self): def run(self):
self.setPriority(QThread.IdlePriority) self.setPriority(QThread.IdlePriority)
@@ -1453,13 +1489,13 @@ class ImageScanner(QThread):
more_files_available = Signal(int, int) # Last loaded index, remainder more_files_available = Signal(int, int) # Last loaded index, remainder
def __init__(self, cache, paths, is_file_list=False, viewers=None, def __init__(self, cache, paths, is_file_list=False, viewers=None,
thread_pool_manager=None): thread_pool_manager=None, target_sizes=None):
# is_file_list is not used
if not paths or not isinstance(paths, (list, tuple)): if not paths or not isinstance(paths, (list, tuple)):
logger.warning("ImageScanner initialized with empty or invalid paths") logger.warning("ImageScanner initialized with empty or invalid paths")
paths = [] paths = []
super().__init__() super().__init__()
self.cache = cache self.cache = cache
self.target_sizes = target_sizes
self.all_files = [] self.all_files = []
self.thread_pool_manager = thread_pool_manager self.thread_pool_manager = thread_pool_manager
self._viewers = viewers self._viewers = viewers
@@ -1816,7 +1852,8 @@ class ImageScanner(QThread):
return return
for f_path, _ in tasks: for f_path, _ in tasks:
r = ScannerWorker(self.cache, f_path, semaphore=sem) r = ScannerWorker(
self.cache, f_path, semaphore=sem, target_sizes=self.target_sizes)
r.setAutoDelete(False) r.setAutoDelete(False)
runnables.append(r) runnables.append(r)
self._current_workers.append(r) self._current_workers.append(r)
@@ -1914,3 +1951,4 @@ class ImageScanner(QThread):
self.mutex.lock() self.mutex.lock()
self.condition.wakeAll() self.condition.wakeAll()
self.mutex.unlock() self.mutex.unlock()
self.wait()

View File

@@ -26,6 +26,7 @@ from PySide6.QtCore import (
Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF, Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF,
QThread, QObject QThread, QObject
) )
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from constants import ( from constants import (
APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS, APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS,
@@ -238,7 +239,10 @@ class FastTagManager:
current_path = controller.get_current_path() if controller else None current_path = controller.get_current_path() if controller else None
if not current_path: if not current_path:
return return
try:
controller.toggle_tag(tag_name, is_checked) controller.toggle_tag(tag_name, is_checked)
except Exception as e:
QMessageBox.critical(self.viewer, UITexts.ERROR, str(e))
self.viewer.update_status_bar() self.viewer.update_status_bar()
if self.main_win: if self.main_win:
if is_checked: if is_checked:
@@ -641,8 +645,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."""
@@ -1011,8 +1017,12 @@ class FaceCanvas(QLabel):
history = history_list \ history = history_list \
if self.viewer.main_win else [] if self.viewer.main_win else []
setting_key = f"{region_type.lower()}_use_last_name"
suggested = history[0] if history and APP_CONFIG.get(
setting_key, False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name( full_tag, updated_history, ok = FaceNameDialog.get_name(
self.viewer, history, self.viewer, history, current_name=suggested,
main_win=self.viewer.main_win, region_type=region_type) main_win=self.viewer.main_win, region_type=region_type)
if ok and full_tag: if ok and full_tag:
@@ -1145,7 +1155,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,31 +1168,33 @@ 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()
v_point = viewport.mapFrom(self.viewer, focus_point) v_point = viewport.mapFrom(self.viewer, focus_point)
c_point = self.viewer.canvas.mapFrom(viewport, v_point) c_point = self.viewer.canvas.mapFrom(viewport, v_point)
else: else:
# 1. Determinar el punto de enfoque en coordenadas del viewport # 1. Determine focus point in viewport coordinates
scroll_area = self.viewer.scroll_area scroll_area = self.viewer.scroll_area
viewport = scroll_area.viewport() viewport = scroll_area.viewport()
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 is relative to the self.viewer widget
# (ImageViewer or 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. Map focus point to canvas coordinates before zoom
c_point = self.viewer.canvas.mapFrom(viewport, v_point) c_point = self.viewer.canvas.mapFrom(viewport, v_point)
self.viewer.controller.zoom_factor *= factor self.viewer.controller.zoom_factor *= factor
# Aplicar la actualización (esto redimensiona el canvas) # Apply update (this resizes the 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. Adjust scrollbars to maintain pixel under 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 +1734,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 +1776,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 +1795,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."""
@@ -2823,8 +2839,11 @@ class ImageViewer(QWidget):
self.main_win.face_names_history = updated_history self.main_win.face_names_history = updated_history
# Save changes and add new tag # Save changes and add new tag
try:
self.controller.save_faces() self.controller.save_faces()
self.controller.toggle_tag(new_full_tag, True) self.controller.toggle_tag(new_full_tag, True)
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
if self.canvas: if self.canvas:
self.canvas.update() self.canvas.update()
@@ -3106,8 +3125,10 @@ class ImageViewer(QWidget):
QApplication.processEvents() QApplication.processEvents()
history = self.main_win.face_names_history if self.main_win else [] history = self.main_win.face_names_history if self.main_win else []
suggested = history[0] if history and APP_CONFIG.get(
"face_use_last_name", False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name( full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, main_win=self.main_win) self, history, current_name=suggested, main_win=self.main_win)
if ok and full_tag: if ok and full_tag:
new_face['name'] = full_tag new_face['name'] = full_tag
@@ -3122,7 +3143,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_pet_detection(self): def run_pet_detection(self):
"""Runs pet detection on the current image.""" """Runs pet detection on the current image."""
@@ -3163,8 +3187,11 @@ class ImageViewer(QWidget):
QApplication.processEvents() QApplication.processEvents()
history = self.main_win.pet_names_history if self.main_win else [] history = self.main_win.pet_names_history if self.main_win else []
suggested = history[0] if history and APP_CONFIG.get(
"pet_use_last_name", False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name( full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, main_win=self.main_win, region_type="Pet") self, history, current_name=suggested, main_win=self.main_win,
region_type="Pet")
if ok and full_tag: if ok and full_tag:
new_pet['name'] = full_tag new_pet['name'] = full_tag
@@ -3178,7 +3205,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_body_detection(self): def run_body_detection(self):
"""Runs body detection on the current image.""" """Runs body detection on the current image."""
@@ -3221,8 +3251,11 @@ class ImageViewer(QWidget):
# For bodies, we typically don't ask for a name immediately unless desired # For bodies, we typically don't ask for a name immediately unless desired
# Or we can treat it like pets/faces and ask. Let's ask. # Or we can treat it like pets/faces and ask. Let's ask.
history = self.main_win.body_names_history if self.main_win else [] history = self.main_win.body_names_history if self.main_win else []
suggested = history[0] if history and APP_CONFIG.get(
"body_use_last_name", False) else ""
full_tag, updated_history, ok = FaceNameDialog.get_name( full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, main_win=self.main_win, region_type="Body") self, history, current_name=suggested, main_win=self.main_win,
region_type="Body")
if ok and full_tag: if ok and full_tag:
new_body['name'] = full_tag new_body['name'] = full_tag
@@ -3236,7 +3269,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def toggle_filmstrip(self): def toggle_filmstrip(self):
"""Shows or hides the filmstrip widget.""" """Shows or hides the filmstrip widget."""
@@ -3309,7 +3345,8 @@ class ImageViewer(QWidget):
# A standard tick is 120. We define a threshold based on speed. # A standard tick is 120. We define a threshold based on speed.
# Speed 1 (slowest) requires a full 120 delta. # Speed 1 (slowest) requires a full 120 delta.
# Speed 10 (fastest) requires 120/10 = 12 delta. # Speed 10 (fastest) requires 120/10 = 12 delta.
threshold = 120 / speed # Still too fast so speed / 2.
threshold = 120 / speed * 2
self._wheel_scroll_accumulator += event.angleDelta().y() self._wheel_scroll_accumulator += event.angleDelta().y()
@@ -3416,17 +3453,18 @@ class ImageViewer(QWidget):
service, which is common on Linux desktops. service, which is common on Linux desktops.
""" """
try: try:
cmd = [ msg = QDBusMessage.createMethodCall(
"dbus-send", "--session", "--print-reply", "org.freedesktop.ScreenSaver",
"--dest=org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver", "/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver.Inhibit", "org.freedesktop.ScreenSaver",
"string:bagheeraview", # Application name "Inhibit"
"string:Viewing images" # Reason for inhibition )
] msg.setArguments(["bagheeraview", "Viewing images"])
output = subprocess.check_output(cmd, text=True) reply = QDBusConnection.sessionBus().call(msg)
# Extract the cookie from the output (e.g., "uint32 12345") if reply.type() == QDBusMessage.ReplyMessage:
self.inhibit_cookie = int(output.split()[-1]) self.inhibit_cookie = reply.arguments()[0]
else:
self.inhibit_cookie = None
except Exception as e: except Exception as e:
print(f"{UITexts.ERROR} inhibiting power management: {e}") print(f"{UITexts.ERROR} inhibiting power management: {e}")
self.inhibit_cookie = None self.inhibit_cookie = None
@@ -3440,13 +3478,14 @@ class ImageViewer(QWidget):
""" """
if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None: if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None:
try: try:
subprocess.Popen([ msg = QDBusMessage.createMethodCall(
"dbus-send", "--session", "org.freedesktop.ScreenSaver",
"--dest=org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver", "/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver.UnInhibit", "org.freedesktop.ScreenSaver",
f"uint32:{self.inhibit_cookie}" "UnInhibit"
]) )
msg.setArguments([self.inhibit_cookie])
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
self.inhibit_cookie = None self.inhibit_cookie = None
except Exception as e: except Exception as e:
print(f"{UITexts.ERROR} uninhibiting: {e}") print(f"{UITexts.ERROR} uninhibiting: {e}")

View File

@@ -9,6 +9,7 @@ Classes:
""" """
import os import os
import collections import collections
import logging
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
try: try:
import exiv2 import exiv2
@@ -16,13 +17,31 @@ try:
except ImportError: except ImportError:
exiv2 = None exiv2 = None
HAVE_EXIV2 = False HAVE_EXIV2 = False
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, UITexts
logger = logging.getLogger(__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)
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): 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.
@@ -106,6 +125,74 @@ class MetadataManager:
return all_metadata return all_metadata
@staticmethod
def write_metadata(path, metadata_dict):
"""
Writes EXIF, IPTC, and XMP metadata back to a file.
Args:
path (str): The path to the image file.
metadata_dict (dict): A dictionary of metadata keys and values.
"""
if not HAVE_EXIV2:
return
try:
image = exiv2.ImageFactory.open(path)
image.readMetadata()
exif = image.exifData()
iptc = image.iptcData()
xmp = image.xmpData()
# Remove keys that are no longer in the dictionary
containers = [
(exif, exiv2.ExifKey, "Exif."),
(iptc, exiv2.IptcKey, "Iptc."),
(xmp, exiv2.XmpKey, "Xmp.")
]
for container, key_class, prefix in containers:
keys_to_remove = []
for datum in container:
k = datum.key()
# Only consider keys belonging to this specific container
if k.startswith(prefix) and k not in metadata_dict:
keys_to_remove.append(k)
for key in keys_to_remove:
try:
x_key = key_class(key)
it = container.findKey(x_key)
if it != container.end():
container.erase(it)
except Exception as e:
print(f"Error removing metadata key {key}: {e}")
# Set or update values from the dictionary
for key, value in metadata_dict.items():
try:
if key.startswith("Exif."):
exif[key] = str(value)
elif key.startswith("Iptc."):
iptc[key] = str(value)
elif key.startswith("Xmp."):
xmp[key] = str(value)
except Exception as e:
print(f"Error setting metadata key {key}: {e}")
image.writeMetadata()
notify_baloo(path)
mark_app_modified(path)
except Exception as e:
error_msg = str(e)
if "kerTooLargeJpegSegment" in error_msg or "38" in error_msg:
msg = UITexts.ERROR_JPEG_METADATA_LIMIT.format(os.path.basename(path))
logger.error(msg)
raise IOError(msg) from e
logger.error(f"Error writing metadata for {path}: {e}")
raise
class XattrManager: class XattrManager:
"""A manager class to handle reading and writing extended attributes (xattrs).""" """A manager class to handle reading and writing extended attributes (xattrs)."""
@@ -148,6 +235,7 @@ class XattrManager:
return return
try: try:
with preserve_mtime(file_path): with preserve_mtime(file_path):
mark_app_modified(file_path)
if value: if value:
os.setxattr(file_path, attr_name, str(value).encode('utf-8')) os.setxattr(file_path, attr_name, str(value).encode('utf-8'))
else: else:

View File

@@ -12,7 +12,7 @@ Classes:
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QFormLayout, QDialogButtonBox, QApplication QFormLayout, QDialogButtonBox, QApplication, QToolBar, QAbstractItemView
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QImageReader, QIcon, QColor QImageReader, QIcon, QColor
@@ -76,6 +76,8 @@ class PropertiesDialog(QDialog):
self.setWindowTitle(UITexts.PROPERTIES_TITLE) self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else [] self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating self._initial_rating = initial_rating
self.original_xattrs = {}
self.original_exif = {}
self.loader = None self.loader = None
self.resize(400, 500) self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
@@ -136,21 +138,25 @@ class PropertiesDialog(QDialog):
meta_widget = QWidget() meta_widget = QWidget()
meta_layout = QVBoxLayout(meta_widget) meta_layout = QVBoxLayout(meta_widget)
self.meta_toolbar = QToolBar()
self._setup_table_toolbar(
self.meta_toolbar, self.on_add_meta, self.on_delete_meta,
self.on_delete_all_meta, self.on_save_meta, self.on_cancel_meta)
meta_layout.addWidget(self.meta_toolbar)
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(2) self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
QHeaderView.ResizeToContents)
self.table.setColumnWidth(0, self.width() * 0.4)
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True) self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QTableWidget.DoubleClicked | self.table.setEditTriggers(QAbstractItemView.DoubleClicked |
QTableWidget.EditKeyPressed | QAbstractItemView.EditKeyPressed |
QTableWidget.SelectedClicked) QAbstractItemView.AnyKeyPressed)
self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.table.itemChanged.connect(self.on_item_changed)
self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu) self.table.customContextMenuRequested.connect(self.show_context_menu)
@@ -164,6 +170,12 @@ class PropertiesDialog(QDialog):
exif_widget = QWidget() exif_widget = QWidget()
exif_layout = QVBoxLayout(exif_widget) exif_layout = QVBoxLayout(exif_widget)
self.exif_toolbar = QToolBar()
self._setup_table_toolbar(
self.exif_toolbar, self.on_add_exif, self.on_delete_exif,
self.on_delete_all_exif, self.on_save_exif, self.on_cancel_exif)
exif_layout.addWidget(self.exif_toolbar)
self.exif_table = QTableWidget() self.exif_table = QTableWidget()
# This table will display EXIF/XMP/IPTC data. # This table will display EXIF/XMP/IPTC data.
# Reading this data involves opening the file with exiv2, which is a disk read. # Reading this data involves opening the file with exiv2, which is a disk read.
@@ -174,14 +186,15 @@ class PropertiesDialog(QDialog):
# without a significant architectural change (e.g., a dedicated metadata DB). # without a significant architectural change (e.g., a dedicated metadata DB).
self.exif_table.setColumnCount(2) self.exif_table.setColumnCount(2)
self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.exif_table.horizontalHeader().setSectionResizeMode( self.exif_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
0, QHeaderView.ResizeToContents) self.exif_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.exif_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.exif_table.verticalHeader().setVisible(False) self.exif_table.verticalHeader().setVisible(False)
self.exif_table.setAlternatingRowColors(True) self.exif_table.setAlternatingRowColors(True)
self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers) self.exif_table.setEditTriggers(QAbstractItemView.DoubleClicked |
QAbstractItemView.EditKeyPressed |
QAbstractItemView.AnyKeyPressed)
self.exif_table.setSelectionBehavior(QTableWidget.SelectRows) self.exif_table.setSelectionBehavior(QTableWidget.SelectRows)
self.exif_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu) self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu)
# This is a disk read. # This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
@@ -204,6 +217,95 @@ class PropertiesDialog(QDialog):
# Start background loading # Start background loading
self.reload_metadata() self.reload_metadata()
def _setup_table_toolbar(self, toolbar, add_slot, del_slot, del_all_slot, save_slot,
cancel_slot):
"""Helper to populate toolbars with buttons."""
toolbar.addAction(QIcon.fromTheme("list-add"), UITexts.CREATE, add_slot)
toolbar.addAction(QIcon.fromTheme("list-remove"), UITexts.DELETE, del_slot)
toolbar.addAction(
QIcon.fromTheme("edit-clear-all"), UITexts.PROPERTIES_DELETE_ALL,
del_all_slot)
toolbar.addSeparator()
toolbar.addAction(QIcon.fromTheme("document-save"), UITexts.SAVE, save_slot)
toolbar.addAction(QIcon.fromTheme("edit-undo"), UITexts.CANCEL, cancel_slot)
def on_add_meta(self):
key, ok = QInputDialog.getText(
self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME)
if ok and key:
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(key))
v_item = QTableWidgetItem("")
self.table.setItem(row, 1, v_item)
self.table.setCurrentItem(v_item)
self.table.editItem(v_item)
def on_delete_meta(self):
rows = sorted(set(index.row() for index in self.table.selectedIndexes()),
reverse=True)
for row in rows:
self.table.removeRow(row)
def on_delete_all_meta(self):
self.table.setRowCount(0)
def on_save_meta(self):
new_attrs = {}
for r in range(self.table.rowCount()):
k_item, v_item = self.table.item(r, 0), self.table.item(r, 1)
if k_item and v_item:
new_attrs[k_item.text()] = v_item.text()
try:
for k in self.original_xattrs:
if k not in new_attrs:
XattrManager.set_attribute(self.path, k, None)
for k, v in new_attrs.items():
XattrManager.set_attribute(self.path, k, v)
self.reload_metadata()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def on_cancel_meta(self):
self.update_metadata_table(self.original_xattrs)
def on_add_exif(self):
key, ok = QInputDialog.getText(
self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME)
if ok and key:
row = self.exif_table.rowCount()
self.exif_table.insertRow(row)
self.exif_table.setItem(row, 0, QTableWidgetItem(key))
v_item = QTableWidgetItem("")
self.exif_table.setItem(row, 1, v_item)
self.exif_table.setCurrentItem(v_item)
self.exif_table.editItem(v_item)
def on_delete_exif(self):
rows = sorted(
set(index.row() for index in self.exif_table.selectedIndexes()),
reverse=True)
for row in rows:
self.exif_table.removeRow(row)
def on_delete_all_exif(self):
self.exif_table.setRowCount(0)
def on_save_exif(self):
new_exif = {}
for r in range(self.exif_table.rowCount()):
k_item, v_item = self.exif_table.item(r, 0), self.exif_table.item(r, 1)
if k_item and v_item:
new_exif[k_item.text()] = v_item.text()
try:
MetadataManager.write_metadata(self.path, new_exif)
self.reload_metadata()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def on_cancel_exif(self):
self.update_exif_table(self.original_exif)
def done(self, r): def done(self, r):
if self.loader and self.loader.isRunning(): if self.loader and self.loader.isRunning():
self.loader.stop() self.loader.stop()
@@ -232,6 +334,7 @@ class PropertiesDialog(QDialog):
# Combine preloaded and newly read xattrs # Combine preloaded and newly read xattrs
all_xattrs = preloaded_xattrs.copy() all_xattrs = preloaded_xattrs.copy()
if not initial_only and disk_xattrs: if not initial_only and disk_xattrs:
self.original_xattrs = disk_xattrs.copy()
# Disk data takes precedence or adds to it # Disk data takes precedence or adds to it
all_xattrs.update(disk_xattrs) all_xattrs.update(disk_xattrs)
@@ -242,9 +345,9 @@ class PropertiesDialog(QDialog):
for key, val in all_xattrs.items(): for key, val in all_xattrs.items():
# QImageReader.textKeys() is not used here as it's not xattr. # QImageReader.textKeys() is not used here as it's not xattr.
k_item = QTableWidgetItem(key) k_item = QTableWidgetItem(key)
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
v_item = QTableWidgetItem(val) v_item = QTableWidgetItem(val)
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
self.table.setItem(row, 0, k_item) self.table.setItem(row, 0, k_item)
self.table.setItem(row, 1, v_item) self.table.setItem(row, 1, v_item)
row += 1 row += 1
@@ -303,6 +406,7 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False) self.exif_table.blockSignals(False)
return return
self.original_exif = exif_data.copy()
self.exif_table.setRowCount(len(exif_data)) self.exif_table.setRowCount(len(exif_data))
error_color = QColor("red") error_color = QColor("red")
error_text_lower = UITexts.ERROR.lower() error_text_lower = UITexts.ERROR.lower()
@@ -310,9 +414,9 @@ class PropertiesDialog(QDialog):
for row, (key, value) in enumerate(sorted(exif_data.items())): for row, (key, value) in enumerate(sorted(exif_data.items())):
k_item = QTableWidgetItem(str(key)) k_item = QTableWidgetItem(str(key))
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
v_item = QTableWidgetItem(str(value)) v_item = QTableWidgetItem(str(value))
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable)
key_str_lower = str(key).lower() key_str_lower = str(key).lower()
val_str_lower = str(value).lower() val_str_lower = str(value).lower()
@@ -328,25 +432,6 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False) self.exif_table.blockSignals(False)
def on_item_changed(self, item):
"""
Slot that triggers when an item in the metadata table is changed.
Args:
item (QTableWidgetItem): The item that was changed.
"""
if item.column() == 1:
key = self.table.item(item.row(), 0).text()
val = item.text()
# Treat empty or whitespace-only values as removal to match previous
# behavior
val_to_set = val if val.strip() else None
try:
XattrManager.set_attribute(self.path, key, val_to_set)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
def show_context_menu(self, pos): def show_context_menu(self, pos):
""" """
Displays a context menu in the metadata table. Displays a context menu in the metadata table.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bagheeraview" name = "bagheeraview"
version = "0.9.16" version = "0.9.26"
authors = [ authors = [
{ name = "Ignacio Serantes" } { name = "Ignacio Serantes" }
] ]
@@ -56,8 +56,11 @@ py-modules = [
"imagecontroller", "imagecontroller",
"metadatamanager", "metadatamanager",
"propertiesdialog", "propertiesdialog",
"thumbnailwidget", #"thumbnailwidget",
"duplicatecache",
"duplicatedialog",
"widgets", "widgets",
"filesystemwatcher",
"xmpmanager", "xmpmanager",
"utils" "utils"
] ]

View File

@@ -55,6 +55,7 @@ class DuplicateFileCounter(QThread):
def stop(self): def stop(self):
self._abort = True self._abort = True
self.wait()
def run(self): def run(self):
count = 0 count = 0
@@ -67,7 +68,8 @@ class DuplicateFileCounter(QThread):
if self._abort: if self._abort:
break break
abs_root = os.path.abspath(root) abs_root = os.path.abspath(root)
dirs[:] = [d for d in dirs if os.path.join(abs_root, d) not in self.blacklist] dirs[:] = [d for d in dirs
if os.path.join(abs_root, d) not in self.blacklist]
if abs_root in self.blacklist: if abs_root in self.blacklist:
continue continue
for f in files: for f in files:
@@ -421,7 +423,8 @@ class SettingsDialog(QDialog):
method_layout = QHBoxLayout() method_layout = QHBoxLayout()
method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL) method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL)
self.duplicate_method_combo = QComboBox() self.duplicate_method_combo = QComboBox()
self.duplicate_method_combo.addItem(UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing") self.duplicate_method_combo.addItem(
UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing")
self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet") self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet")
self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH) self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH)
@@ -436,7 +439,8 @@ class SettingsDialog(QDialog):
method_layout.addWidget(method_label) method_layout.addWidget(method_label)
method_layout.addWidget(self.duplicate_method_combo) method_layout.addWidget(self.duplicate_method_combo)
method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP) method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
self.duplicate_method_combo.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP) self.duplicate_method_combo.setToolTip(
UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
duplicates_layout.addLayout(method_layout) duplicates_layout.addLayout(method_layout)
threshold_layout = QHBoxLayout() threshold_layout = QHBoxLayout()
@@ -453,7 +457,8 @@ class SettingsDialog(QDialog):
threshold_layout.addWidget(self.duplicate_threshold_value_label) threshold_layout.addWidget(self.duplicate_threshold_value_label)
threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP) threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
self.duplicate_threshold_slider.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP) self.duplicate_threshold_slider.setToolTip(
UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
self.duplicate_threshold_slider.valueChanged.connect( self.duplicate_threshold_slider.valueChanged.connect(
lambda v: self.duplicate_threshold_value_label.setText(f"{v}%")) lambda v: self.duplicate_threshold_value_label.setText(f"{v}%"))
@@ -484,14 +489,16 @@ class SettingsDialog(QDialog):
# Whitelist # Whitelist
wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui( wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui(
UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL, UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP) UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL,
UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP)
wl_add.clicked.connect(self.add_whitelist_path) wl_add.clicked.connect(self.add_whitelist_path)
wl_rem.clicked.connect(self.remove_whitelist_path) wl_rem.clicked.connect(self.remove_whitelist_path)
duplicates_layout.addWidget(wl_cont) duplicates_layout.addWidget(wl_cont)
# Blacklist # Blacklist
bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui( bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui(
UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL, UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP) UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL,
UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP)
bl_add.clicked.connect(self.add_blacklist_path) bl_add.clicked.connect(self.add_blacklist_path)
bl_rem.clicked.connect(self.remove_blacklist_path) bl_rem.clicked.connect(self.remove_blacklist_path)
duplicates_layout.addWidget(bl_cont) duplicates_layout.addWidget(bl_cont)
@@ -499,7 +506,8 @@ class SettingsDialog(QDialog):
# Image Count Layout # Image Count Layout
count_layout = QHBoxLayout() count_layout = QHBoxLayout()
self.duplicate_scan_count_label = QLabel() self.duplicate_scan_count_label = QLabel()
self.duplicate_scan_count_label.setStyleSheet("color: #3498db; font-weight: bold;") self.duplicate_scan_count_label.setStyleSheet(
"color: #3498db; font-weight: bold;")
self.duplicate_scan_progress = QProgressBar() self.duplicate_scan_progress = QProgressBar()
self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode
self.duplicate_scan_progress.setFixedHeight(10) self.duplicate_scan_progress.setFixedHeight(10)
@@ -516,20 +524,27 @@ class SettingsDialog(QDialog):
self.count_update_timer.setInterval(500) self.count_update_timer.setInterval(500)
self.count_update_timer.timeout.connect(self.update_duplicate_scan_count) self.count_update_timer.timeout.connect(self.update_duplicate_scan_count)
self.duplicate_whitelist_list.model().rowsInserted.connect(lambda *args: self.count_update_timer.start()) self.duplicate_whitelist_list.model().rowsInserted.connect(
self.duplicate_whitelist_list.model().rowsRemoved.connect(lambda *args: self.count_update_timer.start()) lambda *args: self.count_update_timer.start())
self.duplicate_blacklist_list.model().rowsInserted.connect(lambda *args: self.count_update_timer.start()) self.duplicate_whitelist_list.model().rowsRemoved.connect(
self.duplicate_blacklist_list.model().rowsRemoved.connect(lambda *args: self.count_update_timer.start()) lambda *args: self.count_update_timer.start())
self.duplicate_blacklist_list.model().rowsInserted.connect(
lambda *args: self.count_update_timer.start())
self.duplicate_blacklist_list.model().rowsRemoved.connect(
lambda *args: self.count_update_timer.start())
self.default_delete_to_trash_checkbox = QCheckBox(UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL) self.default_delete_to_trash_checkbox = QCheckBox(
self.default_delete_to_trash_checkbox.setToolTip(UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP) UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL)
self.default_delete_to_trash_checkbox.setToolTip(
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP)
duplicates_layout.addWidget(self.default_delete_to_trash_checkbox) duplicates_layout.addWidget(self.default_delete_to_trash_checkbox)
duplicates_layout.addLayout(threshold_layout) duplicates_layout.addLayout(threshold_layout)
self.duplicate_confirm_delete_checkbox = QCheckBox(UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL) self.duplicate_confirm_delete_checkbox = QCheckBox(
self.duplicate_confirm_delete_checkbox.setToolTip(UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP) UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL)
self.duplicate_confirm_delete_checkbox.setToolTip(
UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP)
duplicates_layout.addWidget(self.duplicate_confirm_delete_checkbox) duplicates_layout.addWidget(self.duplicate_confirm_delete_checkbox)
duplicates_layout.addStretch() duplicates_layout.addStretch()
@@ -599,6 +614,10 @@ class SettingsDialog(QDialog):
self.face_history_spin.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP) self.face_history_spin.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP)
faces_layout.addLayout(face_history_layout) faces_layout.addLayout(face_history_layout)
self.face_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.face_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.face_use_last_name_check)
# --- Pets Section --- # --- Pets Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
pets_header = QLabel(UITexts.TYPE_PET) pets_header = QLabel(UITexts.TYPE_PET)
@@ -655,6 +674,10 @@ class SettingsDialog(QDialog):
self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP) self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP)
faces_layout.addLayout(pet_history_layout) faces_layout.addLayout(pet_history_layout)
self.pet_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.pet_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.pet_use_last_name_check)
# --- Body Section --- # --- Body Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
body_header = QLabel(UITexts.TYPE_BODY) body_header = QLabel(UITexts.TYPE_BODY)
@@ -702,6 +725,10 @@ class SettingsDialog(QDialog):
self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP) self.body_history_spin.setToolTip(UITexts.SETTINGS_BODY_HISTORY_TOOLTIP)
faces_layout.addLayout(body_history_layout) faces_layout.addLayout(body_history_layout)
self.body_use_last_name_check = QCheckBox(UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.body_use_last_name_check.setToolTip(UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.body_use_last_name_check)
# --- Object Section --- # --- Object Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
object_header = QLabel(UITexts.TYPE_OBJECT) object_header = QLabel(UITexts.TYPE_OBJECT)
@@ -748,6 +775,12 @@ class SettingsDialog(QDialog):
self.object_history_spin.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP) self.object_history_spin.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP)
faces_layout.addLayout(object_history_layout) faces_layout.addLayout(object_history_layout)
self.object_use_last_name_check = QCheckBox(
UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.object_use_last_name_check.setToolTip(
UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.object_use_last_name_check)
# --- Landmark Section --- # --- Landmark Section ---
faces_layout.addSpacing(10) faces_layout.addSpacing(10)
landmark_header = QLabel(UITexts.TYPE_LANDMARK) landmark_header = QLabel(UITexts.TYPE_LANDMARK)
@@ -795,6 +828,12 @@ class SettingsDialog(QDialog):
faces_layout.addLayout(landmark_history_layout) faces_layout.addLayout(landmark_history_layout)
faces_layout.addStretch() faces_layout.addStretch()
self.landmark_use_last_name_check = QCheckBox(
UITexts.SETTINGS_USE_LAST_NAME_LABEL)
self.landmark_use_last_name_check.setToolTip(
UITexts.SETTINGS_USE_LAST_NAME_TOOLTIP)
faces_layout.addWidget(self.landmark_use_last_name_check)
# --- Viewer Tab --- # --- Viewer Tab ---
viewer_wheel_layout = QHBoxLayout() viewer_wheel_layout = QHBoxLayout()
viewer_wheel_label = QLabel(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_LABEL) viewer_wheel_label = QLabel(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_LABEL)
@@ -896,6 +935,12 @@ class SettingsDialog(QDialog):
landmark_history_count = APP_CONFIG.get( landmark_history_count = APP_CONFIG.get(
"landmark_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) "landmark_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT)
face_use_last_name = APP_CONFIG.get("face_use_last_name", False)
pet_use_last_name = APP_CONFIG.get("pet_use_last_name", False)
body_use_last_name = APP_CONFIG.get("body_use_last_name", False)
object_use_last_name = APP_CONFIG.get("object_use_last_name", False)
landmark_use_last_name = APP_CONFIG.get("landmark_use_last_name", False)
thumbs_refresh_interval = APP_CONFIG.get( thumbs_refresh_interval = APP_CONFIG.get(
"thumbnails_refresh_interval", THUMBNAILS_REFRESH_INTERVAL_DEFAULT) "thumbnails_refresh_interval", THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
thumbs_bg_color = APP_CONFIG.get( thumbs_bg_color = APP_CONFIG.get(
@@ -944,10 +989,12 @@ class SettingsDialog(QDialog):
duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True) duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True)
self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete) self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete)
duplicate_whitelist = APP_CONFIG.get("duplicate_whitelist", SCANNER_SETTINGS_DEFAULTS["duplicate_whitelist"]) duplicate_whitelist = APP_CONFIG.get(
"duplicate_whitelist", SCANNER_SETTINGS_DEFAULTS["duplicate_whitelist"])
for p in [x.strip() for x in duplicate_whitelist.split(",") if x.strip()]: for p in [x.strip() for x in duplicate_whitelist.split(",") if x.strip()]:
self._add_path_to_list(self.duplicate_whitelist_list, p) self._add_path_to_list(self.duplicate_whitelist_list, p)
duplicate_blacklist = APP_CONFIG.get("duplicate_blacklist", SCANNER_SETTINGS_DEFAULTS["duplicate_blacklist"]) duplicate_blacklist = APP_CONFIG.get(
"duplicate_blacklist", SCANNER_SETTINGS_DEFAULTS["duplicate_blacklist"])
for p in [x.strip() for x in duplicate_blacklist.split(",") if x.strip()]: for p in [x.strip() for x in duplicate_blacklist.split(",") if x.strip()]:
self._add_path_to_list(self.duplicate_blacklist_list, p) self._add_path_to_list(self.duplicate_blacklist_list, p)
@@ -1009,6 +1056,12 @@ class SettingsDialog(QDialog):
self.object_history_spin.setValue(object_history_count) self.object_history_spin.setValue(object_history_count)
self.landmark_history_spin.setValue(landmark_history_count) self.landmark_history_spin.setValue(landmark_history_count)
self.face_use_last_name_check.setChecked(face_use_last_name)
self.pet_use_last_name_check.setChecked(pet_use_last_name)
self.body_use_last_name_check.setChecked(body_use_last_name)
self.object_use_last_name_check.setChecked(object_use_last_name)
self.landmark_use_last_name_check.setChecked(landmark_use_last_name)
self.thumbs_refresh_spin.setValue(thumbs_refresh_interval) self.thumbs_refresh_spin.setValue(thumbs_refresh_interval)
self.set_thumbs_bg_button_color(thumbs_bg_color) self.set_thumbs_bg_button_color(thumbs_bg_color)
self.set_thumbs_filename_button_color(thumbs_filename_color) self.set_thumbs_filename_button_color(thumbs_filename_color)
@@ -1194,7 +1247,7 @@ class SettingsDialog(QDialog):
elif self.download_model_btn: elif self.download_model_btn:
self.download_model_btn.hide() self.download_model_btn.hide()
# --- Mascotas (Pets) --- # --- Pets ---
if not AVAILABLE_PET_ENGINES: if not AVAILABLE_PET_ENGINES:
self.pet_engine_combo.setEnabled(False) self.pet_engine_combo.setEnabled(False)
self.pet_tags_edit.setEnabled(False) self.pet_tags_edit.setEnabled(False)
@@ -1265,6 +1318,13 @@ class SettingsDialog(QDialog):
APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value() APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value()
APP_CONFIG["thumbnails_refresh_interval"] = self.thumbs_refresh_spin.value() APP_CONFIG["thumbnails_refresh_interval"] = self.thumbs_refresh_spin.value()
APP_CONFIG["face_use_last_name"] = self.face_use_last_name_check.isChecked()
APP_CONFIG["pet_use_last_name"] = self.pet_use_last_name_check.isChecked()
APP_CONFIG["body_use_last_name"] = self.body_use_last_name_check.isChecked()
APP_CONFIG["object_use_last_name"] = self.object_use_last_name_check.isChecked()
APP_CONFIG["landmark_use_last_name"] = \
self.landmark_use_last_name_check.isChecked()
APP_CONFIG["thumbnails_bg_color"] = self.current_thumbs_bg_color APP_CONFIG["thumbnails_bg_color"] = self.current_thumbs_bg_color
APP_CONFIG["thumbnails_filename_color"] = self.current_thumbs_filename_color APP_CONFIG["thumbnails_filename_color"] = self.current_thumbs_filename_color
APP_CONFIG["thumbnails_tags_color"] = self.current_thumbs_tags_color APP_CONFIG["thumbnails_tags_color"] = self.current_thumbs_tags_color
@@ -1285,11 +1345,15 @@ class SettingsDialog(QDialog):
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value() APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value()
APP_CONFIG["duplicate_method"] = self.duplicate_method_combo.currentData() APP_CONFIG["duplicate_method"] = self.duplicate_method_combo.currentData()
APP_CONFIG["duplicate_threshold"] = self.duplicate_threshold_slider.value() APP_CONFIG["duplicate_threshold"] = self.duplicate_threshold_slider.value()
APP_CONFIG["default_delete_to_trash"] = self.default_delete_to_trash_checkbox.isChecked() APP_CONFIG["default_delete_to_trash"] = \
APP_CONFIG["duplicate_confirm_delete"] = self.duplicate_confirm_delete_checkbox.isChecked() self.default_delete_to_trash_checkbox.isChecked()
wl_paths = [self.duplicate_whitelist_list.item(i).text() for i in range(self.duplicate_whitelist_list.count())] APP_CONFIG["duplicate_confirm_delete"] = \
self.duplicate_confirm_delete_checkbox.isChecked()
wl_paths = [self.duplicate_whitelist_list.item(i).text()
for i in range(self.duplicate_whitelist_list.count())]
APP_CONFIG["duplicate_whitelist"] = ",".join(wl_paths) APP_CONFIG["duplicate_whitelist"] = ",".join(wl_paths)
bl_paths = [self.duplicate_blacklist_list.item(i).text() for i in range(self.duplicate_blacklist_list.count())] bl_paths = [self.duplicate_blacklist_list.item(i).text()
for i in range(self.duplicate_blacklist_list.count())]
APP_CONFIG["duplicate_blacklist"] = ",".join(bl_paths) APP_CONFIG["duplicate_blacklist"] = ",".join(bl_paths)
APP_CONFIG["viewer_auto_resize_window"] = \ APP_CONFIG["viewer_auto_resize_window"] = \
@@ -1340,14 +1404,14 @@ class SettingsDialog(QDialog):
self.downloader_thread = None self.downloader_thread = None
def done(self, r): def done(self, r):
self._stop_downloader_thread() # Asegura que el hilo de descarga se detenga y espere self._stop_downloader_thread() # Ensure downloader thread stops and waits.
if self.counter_thread and self.counter_thread.isRunning(): if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop() self.counter_thread.stop()
self.counter_thread.wait() self.counter_thread.wait()
super().done(r) super().done(r)
def closeEvent(self, event): def closeEvent(self, event):
self._stop_downloader_thread() # Asegura que el hilo de descarga se detenga y espere self._stop_downloader_thread() # Ensure downloader thread stops and waits.
if self.counter_thread and self.counter_thread.isRunning(): if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop() self.counter_thread.stop()
self.counter_thread.wait() self.counter_thread.wait()
@@ -1365,22 +1429,23 @@ class SettingsDialog(QDialog):
if existing_p == path: if existing_p == path:
return return
# Si una carpeta padre ya existe, no añadimos esta subcarpeta # If a parent folder already exists, do not add this subfolder.
if path.startswith(existing_p + os.sep): if path.startswith(existing_p + os.sep):
return return
# Si la nueva ruta es padre de una existente, marcamos la existente para borrar # If the new path is a parent of an existing one, mark it for removal.
if existing_p.startswith(path + os.sep): if existing_p.startswith(path + os.sep):
to_remove.append(i) to_remove.append(i)
# Borramos las subcarpetas innecesarias (en orden inverso para no alterar los índices) # Remove unnecessary subfolders (reverse order to not alter indices).
for i in sorted(to_remove, reverse=True): for i in sorted(to_remove, reverse=True):
list_widget.takeItem(i) list_widget.takeItem(i)
item = QListWidgetItem(path) item = QListWidgetItem(path)
if not os.path.isdir(path): if not os.path.isdir(path):
item.setForeground(QColor("red")) item.setForeground(QColor("red"))
item.setToolTip(f"Warning: Path not found or is not a directory: {path}") item.setToolTip(
UITexts.SETTINGS_PATH_NOT_FOUND_WARNING.format(path))
list_widget.addItem(item) list_widget.addItem(item)
def add_whitelist_path(self): def add_whitelist_path(self):
@@ -1392,7 +1457,8 @@ class SettingsDialog(QDialog):
def remove_whitelist_path(self): def remove_whitelist_path(self):
"""Removes the selected folders from the whitelist list.""" """Removes the selected folders from the whitelist list."""
for item in self.duplicate_whitelist_list.selectedItems(): for item in self.duplicate_whitelist_list.selectedItems():
self.duplicate_whitelist_list.takeItem(self.duplicate_whitelist_list.row(item)) self.duplicate_whitelist_list.takeItem(
self.duplicate_whitelist_list.row(item))
def add_blacklist_path(self): def add_blacklist_path(self):
"""Opens a directory dialog to add a folder to the blacklist.""" """Opens a directory dialog to add a folder to the blacklist."""
@@ -1403,10 +1469,12 @@ class SettingsDialog(QDialog):
def remove_blacklist_path(self): def remove_blacklist_path(self):
"""Removes the selected folders from the blacklist list.""" """Removes the selected folders from the blacklist list."""
for item in self.duplicate_blacklist_list.selectedItems(): for item in self.duplicate_blacklist_list.selectedItems():
self.duplicate_blacklist_list.takeItem(self.duplicate_blacklist_list.row(item)) self.duplicate_blacklist_list.takeItem(
self.duplicate_blacklist_list.row(item))
def update_duplicate_scan_count(self): def update_duplicate_scan_count(self):
"""Calculates and updates the count of images in whitelist/blacklist using a background thread.""" """Calculates and updates the count of images in whitelist/blacklist
using a background thread."""
if self.counter_thread and self.counter_thread.isRunning(): if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop() self.counter_thread.stop()
self.counter_thread.wait() self.counter_thread.wait()
@@ -1416,17 +1484,23 @@ class SettingsDialog(QDialog):
blacklist_paths = [self.duplicate_blacklist_list.item(i).text() blacklist_paths = [self.duplicate_blacklist_list.item(i).text()
for i in range(self.duplicate_blacklist_list.count())] for i in range(self.duplicate_blacklist_list.count())]
whitelist = [os.path.abspath(os.path.expanduser(p.strip())) for p in whitelist_paths if p.strip()] whitelist = [os.path.abspath(os.path.expanduser(p.strip()))
blacklist = {os.path.abspath(os.path.expanduser(p.strip())) for p in blacklist_paths if p.strip()} for p in whitelist_paths if p.strip()]
blacklist = {os.path.abspath(os.path.expanduser(p.strip()))
for p in blacklist_paths if p.strip()}
if not whitelist: if not whitelist:
self.duplicate_scan_count_label.setText(UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0)) self.duplicate_scan_count_label.setText(
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0))
self.duplicate_scan_progress.hide() self.duplicate_scan_progress.hide()
return return
self.duplicate_scan_progress.show() self.duplicate_scan_progress.show()
self.counter_thread = DuplicateFileCounter(whitelist, blacklist, IMAGE_EXTENSIONS) self.counter_thread = DuplicateFileCounter(
whitelist, blacklist, IMAGE_EXTENSIONS)
self.counter_thread.count_updated.connect( self.counter_thread.count_updated.connect(
lambda c: self.duplicate_scan_count_label.setText(UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c))) lambda c: self.duplicate_scan_count_label.setText(
self.counter_thread.finished.connect(lambda: self.duplicate_scan_progress.hide()) UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c)))
self.counter_thread.finished.connect(
lambda: self.duplicate_scan_progress.hide())
self.counter_thread.start() self.counter_thread.start()

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="bagheeraview", name="bagheeraview",
version="0.9.16", version="0.9.26",
author="Ignacio Serantes", author="Ignacio Serantes",
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind",
long_description="A fast image viewer built with PySide6, featuring search and " long_description="A fast image viewer built with PySide6, featuring search and "
@@ -39,8 +39,11 @@ setup(
"filesystemwatcher", "filesystemwatcher",
"metadatamanager", "metadatamanager",
"propertiesdialog", "propertiesdialog",
"thumbnailwidget", #"thumbnailwidget",
"duplicatecache",
"duplicatedialog",
"widgets", "widgets",
"filesystemwatcher",
"xmpmanager", "xmpmanager",
"utils" "utils"
], ],

View File

@@ -129,11 +129,19 @@ class TagEditWidget(QWidget):
search_layout = QHBoxLayout() search_layout = QHBoxLayout()
self.search_bar = QLineEdit() self.search_bar = QLineEdit()
self.search_bar.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER) self.search_bar.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER)
# Obtener la altura preferida del QLineEdit para usarla en los botones
line_edit_height = self.search_bar.sizeHint().height()
self.search_bar.setClearButtonEnabled(True) self.search_bar.setClearButtonEnabled(True)
self.btn_add_tag = QPushButton("+") self.btn_add_tag = QPushButton("+")
self.btn_add_tag.setFixedWidth(30) self.btn_add_tag.setFixedSize(30, line_edit_height)
self.btn_add_tag.setToolTip(UITexts.TAG_ADD_TOOLTIP)
self.btn_refresh_tags = QPushButton()
self.btn_refresh_tags.setIcon(QIcon.fromTheme("view-refresh"))
self.btn_refresh_tags.setFixedSize(30, line_edit_height)
self.btn_refresh_tags.setToolTip(UITexts.TAG_REFRESH_TOOLTIP)
search_layout.addWidget(self.search_bar) search_layout.addWidget(self.search_bar)
search_layout.addWidget(self.btn_add_tag) search_layout.addWidget(self.btn_add_tag)
search_layout.addWidget(self.btn_refresh_tags)
layout.addLayout(search_layout) layout.addLayout(search_layout)
# Tag tree view setup # Tag tree view setup
@@ -159,6 +167,7 @@ class TagEditWidget(QWidget):
# Connect signals to slots # Connect signals to slots
self.btn_apply.clicked.connect(self.save_changes) self.btn_apply.clicked.connect(self.save_changes)
self.btn_add_tag.clicked.connect(self.create_new_tag) self.btn_add_tag.clicked.connect(self.create_new_tag)
self.btn_refresh_tags.clicked.connect(self.refresh_available_tags)
self.search_bar.textChanged.connect(self.handle_search) self.search_bar.textChanged.connect(self.handle_search)
self.source_model.itemChanged.connect(self.sync_tags) self.source_model.itemChanged.connect(self.sync_tags)
self.tree_view.search_requested.connect(self.on_search_requested) self.tree_view.search_requested.connect(self.on_search_requested)
@@ -177,6 +186,12 @@ class TagEditWidget(QWidget):
tags in files_data.items()} tags in files_data.items()}
self.refresh_ui() self.refresh_ui()
def refresh_available_tags(self):
"""Manual refresh of available tags from Baloo."""
self.load_available_tags()
self._load_all = True
self.init_data()
def load_available_tags(self): def load_available_tags(self):
"""Loads all known tags from the Baloo index database.""" """Loads all known tags from the Baloo index database."""
db_path = os.path.expanduser("~/.local/share/baloo/index") db_path = os.path.expanduser("~/.local/share/baloo/index")
@@ -399,7 +414,7 @@ class TagEditWidget(QWidget):
if not full_path: if not full_path:
return "" return ""
words = full_path.replace('/', ' ').split() words = full_path.replace('/', ' ').split()
search_terms = [f"tags:'{word}'" for word in words if word] search_terms = [f"tags='{word}'" for word in words if word]
return " ".join(search_terms) return " ".join(search_terms)
def _get_current_query_text(self): def _get_current_query_text(self):
@@ -649,7 +664,6 @@ class LayoutsWidget(QWidget):
item_name = QTableWidgetItem(name) item_name = QTableWidgetItem(name)
item_name.setData(Qt.UserRole, f_path) item_name.setData(Qt.UserRole, f_path)
item_name.setData(Qt.UserRole, f_path) # Store full path in item
item_date = QTableWidgetItem(dt) item_date = QTableWidgetItem(dt)
self.table.setItem(i, 0, item_name) self.table.setItem(i, 0, item_name)
@@ -1342,8 +1356,8 @@ class FaceNameInputWidget(QWidget):
super().__init__(parent) super().__init__(parent)
self.main_win = main_win self.main_win = main_win
self.region_type = region_type self.region_type = region_type
# Usamos deque para gestionar el historial de forma eficiente con un máximo # Use deque to manage history efficiently with a configurable maximum
# configurable de elementos. # number of items.
max_items = APP_CONFIG.get("faces_menu_max_items", max_items = APP_CONFIG.get("faces_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT) FACES_MENU_MAX_ITEMS_DEFAULT)
if self.region_type == "Pet": if self.region_type == "Pet":
@@ -1373,7 +1387,7 @@ class FaceNameInputWidget(QWidget):
self.name_combo.setToolTip(UITexts.FACE_NAME_TOOLTIP) self.name_combo.setToolTip(UITexts.FACE_NAME_TOOLTIP)
self.name_combo.lineEdit().setClearButtonEnabled(True) self.name_combo.lineEdit().setClearButtonEnabled(True)
# 2. Completer para la funcionalidad de autocompletado. # 2. Completer for autocomplete functionality.
self.completer = QCompleter(self) self.completer = QCompleter(self)
self.completer.setCaseSensitivity(Qt.CaseInsensitive) self.completer.setCaseSensitivity(Qt.CaseInsensitive)
self.completer.setFilterMode(Qt.MatchContains) self.completer.setFilterMode(Qt.MatchContains)

View File

@@ -17,13 +17,17 @@ Dependencies:
""" """
import os import os
import re import re
import logging
from utils import preserve_mtime from utils import preserve_mtime
from metadatamanager import notify_baloo from metadatamanager import notify_baloo, mark_app_modified
from constants import UITexts
try: try:
import exiv2 import exiv2
except ImportError: except ImportError:
exiv2 = None exiv2 = None
logger = logging.getLogger(__name__)
class XmpManager: class XmpManager:
""" """
@@ -38,8 +42,9 @@ class XmpManager:
This method parses the XMP data structure for a `mwg-rs:RegionList`, This method parses the XMP data structure for a `mwg-rs:RegionList`,
extracts all regions of type 'Face', and returns them as a list of extracts all regions of type 'Face', and returns them as a list of
dictionaries. Each dictionary contains the face's name and its dictionaries.
normalized coordinates (center x, center y, width, height). Each dictionary contains the face's name and its normalized coordinates
(center x, center y, width, height).
Args: Args:
path (str): The path to the image file. path (str): The path to the image file.
@@ -161,8 +166,15 @@ class XmpManager:
xmp[f"{area_base}/stArea:unit"] = 'normalized' xmp[f"{area_base}/stArea:unit"] = 'normalized'
img.writeMetadata() img.writeMetadata()
notify_baloo(path) notify_baloo(path)
mark_app_modified(path)
return True return True
except Exception as e: except Exception as e:
print(f"Error saving faces to XMP: {e}") error_msg = str(e)
return False if "kerTooLargeJpegSegment" in error_msg or "38" in error_msg:
msg = UITexts.ERROR_JPEG_METADATA_LIMIT.format(os.path.basename(path))
logger.error(msg)
raise IOError(msg) from e
logger.error(f"Error saving faces to XMP: {e}")
raise