Compare commits
21 Commits
415400c30a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bc2f1e0a | ||
|
|
dffc414182 | ||
|
|
0d3d5ffa11 | ||
|
|
8025bef8d3 | ||
|
|
28b120c9e9 | ||
|
|
a824a01579 | ||
|
|
b5b70326b1 | ||
|
|
9d286112b6 | ||
|
|
b253b6d6e7 | ||
|
|
8ade5fde54 | ||
|
|
1508e629c0 | ||
|
|
07afab6ca3 | ||
|
|
bff99226b0 | ||
|
|
9685c01760 | ||
|
|
3e374a5871 | ||
|
|
964974431c | ||
|
|
45c95c1bb1 | ||
|
|
a717acef87 | ||
|
|
ca260d4219 | ||
|
|
ae00235db8 | ||
|
|
2fbf04fdb8 |
320
bagheeraview.py
320
bagheeraview.py
@@ -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,32 +1760,48 @@ 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(
|
||||||
UITexts.MENU_CLEAR_HASHES.format(count, size_mb))
|
QIcon.fromTheme("user-trash-full"),
|
||||||
|
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)
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
@@ -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
|
||||||
res = load_common_metadata(path)
|
try:
|
||||||
mtime = os.path.getmtime(path)
|
new_stat = os.stat(path)
|
||||||
stat_res = os.stat(path)
|
new_mtime = new_stat.st_mtime
|
||||||
inode = stat_res.st_ino
|
new_size = new_stat.st_size
|
||||||
dev = stat_res.st_dev
|
|
||||||
|
|
||||||
# Update internal data and model
|
# Find old data from internal list
|
||||||
self._update_internal_data(path, mtime=mtime, tags=res.tags, rating=res.rating,
|
old_item_data = next((item for item in self.found_items_data
|
||||||
inode=inode, dev=dev)
|
if item[0] == path), None)
|
||||||
self.proxy_model.add_to_cache(path, res.tags)
|
old_mtime = old_item_data[2] if old_item_data else 0
|
||||||
self.rebuild_view()
|
# Re-read size from disk for comparison
|
||||||
self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
|
old_size = os.path.getsize(path) if old_item_data else 0
|
||||||
|
|
||||||
|
if new_size == old_size and new_mtime != old_mtime:
|
||||||
|
# Likely metadata-only change (size unchanged, mtime changed)
|
||||||
|
res = load_common_metadata(path)
|
||||||
|
self._update_internal_data(
|
||||||
|
path, mtime=new_mtime, tags=res.tags, rating=res.rating,
|
||||||
|
inode=new_stat.st_ino, dev=new_stat.st_dev)
|
||||||
|
self.proxy_model.add_to_cache(path, res.tags)
|
||||||
|
self.thumbnail_view.viewport().update() # Force repaint
|
||||||
|
self.status_lbl.setText(f"Metadata updated: {os.path.basename(path)}")
|
||||||
|
else:
|
||||||
|
# Content or size changed, invalidate thumbnail and rebuild view
|
||||||
|
self.cache.invalidate_path(path)
|
||||||
|
res = load_common_metadata(path) # Re-read metadata as well
|
||||||
|
self._update_internal_data(
|
||||||
|
path, mtime=new_mtime, tags=res.tags, rating=res.rating,
|
||||||
|
inode=new_stat.st_ino, dev=new_stat.st_dev)
|
||||||
|
self.proxy_model.add_to_cache(path, res.tags)
|
||||||
|
self.rebuild_view()
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
219
constants.py
219
constants.py
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
new_size_f = transformed_pixmap.size() * self.zoom_factor
|
# as scaling should be based on the *transformed* dimensions.
|
||||||
new_size = QSize(int(new_size_f.width()), int(new_size_f.height()))
|
# This is important: if you scale before rotation, the scaling
|
||||||
|
# factors might be applied to the wrong axes.
|
||||||
scaled_pixmap = transformed_pixmap.scaled(
|
if self.zoom_factor != 1.0:
|
||||||
new_size, Qt.AspectRatioMode.KeepAspectRatio,
|
new_size_f = transformed_pixmap.size() * self.zoom_factor
|
||||||
Qt.TransformationMode.SmoothTransformation)
|
new_size = QSize(int(new_size_f.width()), int(new_size_f.height()))
|
||||||
|
scaled_pixmap = transformed_pixmap.scaled(
|
||||||
if self.flip_h:
|
new_size, Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
t_flip_h = QTransform()
|
Qt.TransformationMode.SmoothTransformation)
|
||||||
t_flip_h.scale(-1, 1)
|
return scaled_pixmap
|
||||||
scaled_pixmap = scaled_pixmap.transformed(
|
else:
|
||||||
t_flip_h, Qt.TransformationMode.SmoothTransformation)
|
return transformed_pixmap
|
||||||
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
|
|
||||||
|
|
||||||
def rotate(self, angle):
|
def rotate(self, angle):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
123
imageviewer.py
123
imageviewer.py
@@ -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
|
||||||
controller.toggle_tag(tag_name, is_checked)
|
try:
|
||||||
|
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:
|
||||||
@@ -424,7 +428,7 @@ class FaceCanvas(QLabel):
|
|||||||
self.zoom_indicator_point = None
|
self.zoom_indicator_point = None
|
||||||
self.zoom_indicator_timer = QTimer(self)
|
self.zoom_indicator_timer = QTimer(self)
|
||||||
self.zoom_indicator_timer.setSingleShot(True)
|
self.zoom_indicator_timer.setSingleShot(True)
|
||||||
self.zoom_indicator_timer.setInterval(500) # Show for 500ms
|
self.zoom_indicator_timer.setInterval(500) # Show for 500ms
|
||||||
self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator)
|
self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator)
|
||||||
self.crop_rect = QRect()
|
self.crop_rect = QRect()
|
||||||
self.crop_handle = None
|
self.crop_handle = None
|
||||||
@@ -636,13 +640,15 @@ class FaceCanvas(QLabel):
|
|||||||
|
|
||||||
# Draw zoom indicator
|
# Draw zoom indicator
|
||||||
if self.zoom_indicator_point:
|
if self.zoom_indicator_point:
|
||||||
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
|
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
|
||||||
painter.drawLine(self.zoom_indicator_point.x() - 10,
|
painter.drawLine(self.zoom_indicator_point.x() - 10,
|
||||||
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
|
||||||
@@ -1155,33 +1166,35 @@ class ZoomManager(QObject):
|
|||||||
self.viewer.update_view(resize_win=True)
|
self.viewer.update_view(resize_win=True)
|
||||||
if self.viewer.canvas:
|
if self.viewer.canvas:
|
||||||
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
|
||||||
self.controller.save_faces()
|
try:
|
||||||
self.controller.toggle_tag(new_full_tag, True)
|
self.controller.save_faces()
|
||||||
|
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:
|
||||||
self.controller.save_faces()
|
try:
|
||||||
|
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:
|
||||||
self.controller.save_faces()
|
try:
|
||||||
|
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:
|
||||||
self.controller.save_faces()
|
try:
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
150
settings.py
150
settings.py
@@ -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()
|
||||||
|
|||||||
7
setup.py
7
setup.py
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
26
widgets.py
26
widgets.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user