Compare commits

...

28 Commits

Author SHA1 Message Date
Ignacio Serantes
f3bc2f1e0a v0.9.26 2026-05-07 22:38:49 +02:00
Ignacio Serantes
dffc414182 v0.9.26 2026-05-07 21:44:19 +02:00
Ignacio Serantes
0d3d5ffa11 V0.9.26 2026-05-07 09:58:49 +02:00
Ignacio Serantes
8025bef8d3 v0.9.26 2026-05-03 13:31:48 +02:00
Ignacio Serantes
28b120c9e9 v0.9.25 2026-05-02 19:44:28 +02:00
Ignacio Serantes
a824a01579 v0.9.24 2026-04-19 17:39:01 +02:00
Ignacio Serantes
b5b70326b1 v0.9.23 2026-04-19 12:18:27 +02:00
Ignacio Serantes
9d286112b6 v0.9.22 2026-04-14 20:59:13 +02:00
Ignacio Serantes
b253b6d6e7 v0.9.21 2026-04-12 11:58:32 +02:00
Ignacio Serantes
8ade5fde54 v0.9.21 2026-04-12 11:56:39 +02:00
Ignacio Serantes
1508e629c0 v0.9.20 2026-04-12 08:39:07 +02:00
Ignacio Serantes
07afab6ca3 v0.9.19 2026-04-08 15:47:29 +02:00
Ignacio Serantes
bff99226b0 v0.9.18 2026-04-07 16:22:59 +02:00
Ignacio Serantes
9685c01760 Better status bar messages 2026-04-07 09:17:08 +02:00
Ignacio Serantes
3e374a5871 v0.9.17 2026-04-06 23:55:29 +02:00
Ignacio Serantes
964974431c Fixed hang with gifs in duplicates form 2026-04-06 23:20:27 +02:00
Ignacio Serantes
45c95c1bb1 Fixed thumbnail reload on metadata change 2026-04-06 22:09:13 +02:00
Ignacio Serantes
a717acef87 Several fixes 2026-04-06 20:44:49 +02:00
Ignacio Serantes
ca260d4219 Improve stability issues 2026-04-03 18:41:52 +02:00
Ignacio Serantes
ae00235db8 Fixed core dumped on close 2026-04-01 08:48:06 +02:00
Ignacio Serantes
2fbf04fdb8 Added missing libraries. 2026-03-31 23:40:29 +02:00
Ignacio Serantes
415400c30a Merge branch 'main' of ssh://git.aynoa.net/ignacio/bagheeraview 2026-03-31 23:36:14 +02:00
Ignacio Serantes
cb751b2970 v0.9.16 2026-03-31 23:35:57 +02:00
3706d404f4 Update README.md 2026-03-30 16:32:09 +02:00
2ae8ba9d9a Update information 2026-03-30 09:47:34 +02:00
Ignacio Serantes
ff7c1aa373 v0.9.15 2026-03-28 07:54:59 +01:00
Ignacio Serantes
d4f3732aa4 Added watchdog support 2026-03-28 07:13:16 +01:00
Ignacio Serantes
096cee6ca3 Added system language detection 2026-03-26 20:09:46 +01:00
18 changed files with 5103 additions and 415 deletions

View File

@@ -4,17 +4,27 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
## 🚀 Key Features ## 🚀 Key Features
- **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**. - **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**, if BagheeraSearch library is available.
- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering both **Flat View**, several **Date View** modes, **Rating View** and **Folder View** modes. - **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering **Flat View**, several **Date View** modes, **Rating View** and **Folder View** modes.
- **Face & Pet Detection:** Integrated computer vision to detect faces and pets within your photos and assign names. Object and Landmark tags are supported to but without computer vision detection. - **Areas Management:** Integrated computer vision to detect faces and pets within your photos and assign tag names. Body, Object and Landmark areas are supported too but without computer vision detection.
- **Metadata:** A basic viewer for **EXIF, IPTC, and XMP** data. - **Metadata:** A basic viewer for **EXIF, IPTC, and XMP** data.
- **Tagging & Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes. - **Tagging, Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes and used by Baloo.
- **Smart State Persistence:** The application remembers your workflow. Your **last used sort order** and view settings are automatically saved and restored upon startup. - **Filter resuls:** Results can be filtered using tags or file name.
- **Duplicates Management:** A system to detect and manage duplicates using percentual hashing or OpenCV including a images comparison form and ignore list.
- **Smart State Persistence:** The application remembers your workflow. Your **last used search**, **last used sort order** and view settings are automatically saved and restored upon startup.
- **Favorites:** Favorite searchs can be saved and reused.
- **Cache:** Thumbnails cache and image hashes arte cached on LMDB databases.
- **Window Manager Support:** X11 and Wayland are supported.
## 🛠 Technical Stack ## 🛠 Technical Stack
@@ -24,8 +34,9 @@ BagheeraView is an image viewer specifically designed for the KDE ecosystem. Bui
- **KDE Integration:** Baloo search and basic management - **KDE Integration:** Baloo search and basic management
- **Metadata Handling:** Advanced image header manipulation to store faces, pets, objects and landmarks and support to file extended attributes - **Metadata Handling:** Advanced image header manipulation to store faces, pets, body, objects and landmarks and support to file extended attributes
- **Duplicate Detection:** Two methods to detect duplicates, hashing and open vision
## 🌐 Internationalization (i18n) ## 🌐 Internationalization (i18n)
@@ -36,23 +47,21 @@ BagheeraView is designed for a global audience with localized interface support.
- **Galician** - **Galician**
- **Spanish** - **Spanish**
> **Note:** Following internal configuration standards, all source code labels and developer logs are maintained in English for technical consistency. > **Note:** Following internal configuration standards, all source code labels and developer logs are maintained in English for technical consistency.
## ⚙️ Configuration & Persistence ## ⚙️ Configuration & Persistence
BagheeraView is built for workflow continuity. The application stores the user's environment state in the local configuration: BagheeraView is built for workflow continuity. The application stores the user's environment state in the local configuration:
- **Restore Last Layout:** Last layout and preferences are automatically saved and restored every time you launch it. - **Restore Last Layout:** Last layout and preferences are automatically saved and restored every  time you launch it.
- **Keyboard configuration:** All hotkeys can be parametriced by the user. - **Keyboard configuration:** All hotkeys can be parametriced by the user.
- **Interface Language:** The application automatically detects the system locale and applies the corresponding translation on startup or user can decide main language. - **Interface Language:** The application automatically detects the system locale and applies the corresponding translation on startup or user can decide main language.
## 📥 Installation (Development) ## 📥 Installation (Development)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your distro. Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
Bash Bash
@@ -68,12 +77,12 @@ pip install -r requirements.txt
python bagheeraview.py python bagheeraview.py
``` ```
BagheeraSearch tool and librery are available at https://git.aynoa.net/ignacio/BagheeraSearch.git BagheeraSearch tool and library are available at https://git.aynoa.net/ignacio/BagheeraSearch.git
## 📥 Installation (Production with BagheeraSearch) ## 📥 Recomended Installation (Production with BagheeraSearch)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your distro. Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
Bash Bash
@@ -83,6 +92,7 @@ cd /tmp
git clone https://git.aynoa.net/ignacio/BagheeraSearch.git git clone https://git.aynoa.net/ignacio/BagheeraSearch.git
git clone https://git.aynoa.net/ignacio/BagheeraView.git git clone https://git.aynoa.net/ignacio/BagheeraView.git
# Create an installation directory
mkdir <a path you like/bagheeraview> mkdir <a path you like/bagheeraview>
cd <a path you like/bagheeraview> cd <a path you like/bagheeraview>
python -m venv --system-site-packages .venv python -m venv --system-site-packages .venv
@@ -96,9 +106,9 @@ pip install . /tmp/BagheeraView
python bagheeraview.py python bagheeraview.py
``` ```
## 📥 Installation (Production without BagheeraSearch) ## 📥 Alternative Installation (Production without BagheeraSearch)
Ensure you have at least Python 3.13, the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system but for better results install all required libraries if they are available in your distro. Ensure you have at least Python 3.13, the necessary PySide6, LMDB and Exiv2 dependencies and KDE development headers for Baloo installed on your system. For best integration install at least PySide6 in your system, but for better results install all required libraries on your system if they are available in your Linux distribution.
Bash Bash
@@ -107,6 +117,7 @@ Bash
cd /tmp cd /tmp
git clone https://git.aynoa.net/ignacio/BagheeraView.git git clone https://git.aynoa.net/ignacio/BagheeraView.git
# Create an installation directory
mkdir <a path you like/bagheeraview> mkdir <a path you like/bagheeraview>
cd <a path you like/bagheeraview> cd <a path you like/bagheeraview>
python -m venv --system-site-packages .venv python -m venv --system-site-packages .venv

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
¿Sería posible añadir una opción para limpiar automáticamente los hashes de archivos que ya no existen sin borrar toda la base de datos?
¿Podrías optimizar el proceso de borrado en lote para que sea más eficiente si hay miles de entradas que limpiar?
Implement a bulk rename feature for the selected pet or face tags. Implement a bulk rename feature for the selected pet or face tags.
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text. Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
@@ -51,6 +55,7 @@ How can I implement a bulk rename feature for the selected pet or face tags?
¿Cómo puedo añadir una opción "Abrir con otra aplicación..." al final del submenú que lance el selector de aplicaciones del sistema? ¿Cómo puedo añadir una opción "Abrir con otra aplicación..." al final del submenú que lance el selector de aplicaciones del sistema?
¿Cómo puedo añadir soporte para arrastrar y soltar imágenes desde el visor (ImageViewer) a otras aplicaciones? ¿Cómo puedo añadir soporte para arrastrar y soltar imágenes desde el visor (ImageViewer) a otras aplicaciones?
· Añadir una opción al menú de contexto para "Abrir con el visor estándar de Bagheera" para ver la imagen a pantalla completa.
¿Por qué la selección de imágenes se pierde o cambia incorrectamente al cambiar el modo de agrupación? ¿Por qué la selección de imágenes se pierde o cambia incorrectamente al cambiar el modo de agrupación?
@@ -62,6 +67,18 @@ 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 -
· Duplicates
v0.9.14 - v0.9.14 -
· Corregido el problema de resolución de los thumbnails · Corregido el problema de resolución de los thumbnails

View File

@@ -29,28 +29,54 @@ 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.14" PROG_VERSION = "0.9.26"
PROG_AUTHOR = "Ignacio Serantes" PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS --- # --- CACHE SETTINGS ---
# Maximum number of thumbnails to keep in the in-memory cache. # Maximum number of paths to track in the in-memory cache.
CACHE_MAX_SIZE = 20000 CACHE_MAX_SIZE = 10000
# Dynamic RAM limit for thumbnails to avoid swapping on low-end systems.
try:
import psutil
_total_ram_bytes = psutil.virtual_memory().total
# Use 10% of system RAM, clamped between 128MB and 512MB
CACHE_MAX_RAM_BYTES = int(max(128 * 1024 * 1024,
min(512 * 1024 * 1024, _total_ram_bytes * 0.10)))
except (ImportError, Exception):
# Fallback to a safe 256MB if psutil is missing or fails
CACHE_MAX_RAM_BYTES = 256 * 1024 * 1024
# Minimum percentage of free system RAM required.
# Aggressive cache pruning will trigger if available memory falls below this.
MIN_FREE_RAM_PERCENT = 5.0
# Maximum size of the persistent disk cache file. # Maximum size of the persistent disk cache file.
# 10 GB limit for persistent cache file # 10 GB limit for persistent cache file
DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024 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_lmdb")
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)
DUPLICATE_CACHE_PATH = os.path.join(APP_DATA_DIR, "duplicates")
DUPLICATE_HASH_DB_NAME = b"hashes"
DUPLICATE_EXCEPTIONS_DB_NAME = b"exceptions"
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():
@@ -60,9 +86,8 @@ def save_app_config():
with open(CONFIG_PATH, 'w', encoding='utf-8') as f: with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
# Use APP_CONFIG global # Use APP_CONFIG global
json.dump(APP_CONFIG, f, indent=4) json.dump(APP_CONFIG, f, indent=4)
except OSError: except Exception as e:
# Silently fail for now, but could log this print(f"CRITICAL: Failed to save configuration to {CONFIG_PATH}: {e}")
pass
# --- CONFIGURATION LOADING --- # --- CONFIGURATION LOADING ---
@@ -117,7 +142,18 @@ SCANNER_SETTINGS_DEFAULTS = {
"scan_full_on_start": True, "scan_full_on_start": True,
"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_method": "histogram_hashing",
"duplicate_confirm_delete": True,
"default_delete_to_trash": True,
"duplicate_whitelist": "",
"duplicate_blacklist": ""
} }
# --- IMAGE VIEWER DEFAULTS --- # --- IMAGE VIEWER DEFAULTS ---
@@ -174,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"
@@ -208,6 +244,16 @@ if HAVE_MEDIAPIPE:
DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None
DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None
HAVE_IMAGEHASH = importlib.util.find_spec("imagehash") is not None
# --- DUPLICATE DETECTION ---
HAVE_DUPLICATE_RESNET_LIBS = all(
importlib.util.find_spec(lib) is not None
for lib in ["torch", "torchvision", "numpy", "sklearn"]
)
MAX_DHASH_DISTANCE = 64 # For 64-bit dHash
DEFAULT_FACE_BOX_COLOR = "#FFFFFF" DEFAULT_FACE_BOX_COLOR = "#FFFFFF"
# Load preferred engine from config, or use the default. # Load preferred engine from config, or use the default.
FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine", FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine",
@@ -341,23 +387,16 @@ DEFAULT_VIEWER_SHORTCUTS = {
# Supported languages # Supported languages
SUPPORTED_LANGUAGES = { SUPPORTED_LANGUAGES = {
"system": "System",
"en": "English", "en": "English",
"es": "Español", "es": "Español",
"gl": "Galego" "gl": "Galego"
} }
# Default language # Default language for configuration
DEFAULT_LANGUAGE = "en" DEFAULT_LANGUAGE = "system"
# Determine current language: # Fallback language for translations
# 1. Environment variable (for debugging/override) FALLBACK_LANGUAGE = "en"
# 2. Saved configuration
# 3. Default
CURRENT_LANGUAGE = os.getenv("BAGHEERA_LANG") or \
APP_CONFIG.get("language", DEFAULT_LANGUAGE)
# Ensure the loaded language is supported, otherwise fallback to default
if CURRENT_LANGUAGE not in SUPPORTED_LANGUAGES:
CURRENT_LANGUAGE = DEFAULT_LANGUAGE
_UI_TEXTS = { _UI_TEXTS = {
"en": { "en": {
@@ -365,11 +404,13 @@ _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",
"SAVE": "Save", "SAVE": "Save",
"CREATE": "Create", "CREATE": "Create",
"CANCEL": "Cancel",
"RENAME": "Rename", "RENAME": "Rename",
"COPY": "Copy", "COPY": "Copy",
"DELETE": "Delete", "DELETE": "Delete",
@@ -480,6 +521,80 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Show Layouts", "MENU_SHOW_LAYOUTS": "Show Layouts",
"MENU_SHOW_HISTORY": "Show History", "MENU_SHOW_HISTORY": "Show History",
"MENU_SETTINGS": "Settings", "MENU_SETTINGS": "Settings",
"SETTINGS_GROUP_DUPLICATES": "Duplicates",
"MENU_DUPLICATES": "Duplicates",
"MENU_DETECT_CURRENT_SEARCH": "Detect in current search",
"MENU_DETECT_ALL": "Detect all",
"MENU_FORCE_FULL_ALL_ANALYSIS": "Force full all analysis",
"MENU_FORCE_FULL_ANALYSIS": "Force full analysis",
"MENU_REVIEW_IGNORED": "Review ignored",
"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)",
"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_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_TOOLTIP": "Select the method for duplicate "
"detection.",
"METHOD_HISTOGRAM_HASHING": "Histogram + Hashing",
"METHOD_RESNET": "ResNet (AI Based)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates",
"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_BLACKLIST_LABEL": "Blacklist (folders to exclude):",
"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_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by "
"default",
"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_TOOLTIP": "Set the similarity threshold 2 "
"(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",
"DUPLICATE_WHITELIST_EMPTY": "Whitelist is empty. Please configure it "
"in Settings.",
"DUPLICATE_DETECTION_TITLE": "Duplicate Detection",
"DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.",
"DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.",
"DUPLICATE_STARTING": "Starting duplicate detection...",
"DUPLICATE_PROGRESS": "Duplicate detection: {message} ({current}/{total})",
"DUPLICATE_NONE_FOUND": "No duplicates found.",
"DUPLICATE_FOUND_TITLE": "Duplicates Found",
"DUPLICATE_FOUND_MSG": "The following duplicates were found:\n",
"DUPLICATE_FOUND_MORE": "... and {count} more.",
"DUPLICATE_FINISHED": "Duplicate detection finished.",
"DUPLICATE_MSG_HASHING": "Hashing {filename}",
"DUPLICATE_MSG_ANALYZING": "Analyzing {filename}",
"DUPLICATE_MANAGER_TITLE": "Manage Duplicate Images",
"DUPLICATE_DELETE_LEFT": "Trash Left",
"DUPLICATE_DELETE_RIGHT": "Trash Right",
"CONFIRM_TRASH_TITLE": "Move to Trash",
"CONFIRM_TRASH_TEXT": "Do you want to move this image to the trash?",
"DUPLICATE_KEEP_BOTH": "Keep Both (Ignore)",
"DUPLICATE_SKIP": "Skip",
"DUPLICATE_REMOVE_IGNORED": "Remove from ignored",
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
"VIEWER_MENU_LINK_PANES": "Link Panes",
"DUPLICATE_OPEN_COMPARISON": "Open Comparison",
"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",
@@ -535,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",
@@ -675,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"],
@@ -711,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",
@@ -745,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: {}",
@@ -797,6 +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_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",
@@ -821,6 +947,7 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "Could not move file: {}", "ERROR_MOVE_FILE": "Could not move file: {}",
"ERROR_COPY_FILE": "Could not copy file: {}", "ERROR_COPY_FILE": "Could not copy file: {}",
"MOVED_TO": "Moved to {}", "MOVED_TO": "Moved to {}",
"FS_WATCHER_TOOLTIP": "File System Watcher (monitoring active directories)",
"COPIED_TO": "Copied to {}", "COPIED_TO": "Copied to {}",
"ERROR_ROTATE_IMAGE": "Could not rotate image: {}", "ERROR_ROTATE_IMAGE": "Could not rotate image: {}",
}, },
@@ -829,11 +956,13 @@ _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",
"SAVE": "Guardar", "SAVE": "Guardar",
"CREATE": "Crear", "CREATE": "Crear",
"CANCEL": "Cancelar",
"RENAME": "Renombrar", "RENAME": "Renombrar",
"COPY": "Copiar", "COPY": "Copiar",
"DELETE": "Eliminar", "DELETE": "Eliminar",
@@ -944,6 +1073,84 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Mostrar Diseños", "MENU_SHOW_LAYOUTS": "Mostrar Diseños",
"MENU_SHOW_HISTORY": "Mostrar Historial", "MENU_SHOW_HISTORY": "Mostrar Historial",
"MENU_SETTINGS": "Opciones", "MENU_SETTINGS": "Opciones",
"SETTINGS_GROUP_DUPLICATES": "Duplicados",
"MENU_DUPLICATES": "Duplicados",
"MENU_DETECT_CURRENT_SEARCH": "Detectar en búsqueda actual",
"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_REVIEW_IGNORED": "Revisar ignorados",
"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)",
"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_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_TOOLTIP": "Selecciona el método para la detección "
"de duplicados.",
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
"METHOD_RESNET": "ResNet (Basado en IA)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
"duplicados",
"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_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_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar "
"todos': {}",
"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_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_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",
"DUPLICATE_WHITELIST_EMPTY": "La lista blanca está vacía. Por favor, "
"configúrela en Opciones.",
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
"DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.",
"DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.",
"DUPLICATE_STARTING": "Iniciando detección de duplicados...",
"DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})",
"DUPLICATE_NONE_FOUND": "No se encontraron duplicados.",
"DUPLICATE_FOUND_TITLE": "Duplicados Encontrados",
"DUPLICATE_FOUND_MSG": "Se encontraron los siguientes duplicados:\n",
"DUPLICATE_FOUND_MORE": "... y {count} más.",
"DUPLICATE_FINISHED": "Detección de duplicados finalizada.",
"DUPLICATE_MSG_HASHING": "Procesando {filename}",
"DUPLICATE_MSG_ANALYZING": "Analizando {filename}",
"DUPLICATE_MANAGER_TITLE": "Gestionar Imágenes Duplicadas",
"DUPLICATE_DELETE_LEFT": "Papelera Izquierda",
"DUPLICATE_DELETE_RIGHT": "Papelera Derecha",
"CONFIRM_TRASH_TITLE": "Mover a la papelera",
"CONFIRM_TRASH_TEXT": "¿Deseas mover esta imagen a la papelera?",
"DUPLICATE_KEEP_BOTH": "Mantener Ambas (Ignorar)",
"DUPLICATE_SKIP": "Omitir",
"DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados",
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
"VIEWER_MENU_LINK_PANES": "Vincular Paneles",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"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",
@@ -1005,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:",
@@ -1145,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"],
@@ -1181,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",
@@ -1216,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: {}",
@@ -1268,6 +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_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",
@@ -1292,6 +1511,8 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "No se pudo mover el archivo: {}", "ERROR_MOVE_FILE": "No se pudo mover el archivo: {}",
"ERROR_COPY_FILE": "No se pudo copiar el archivo: {}", "ERROR_COPY_FILE": "No se pudo copiar el archivo: {}",
"MOVED_TO": "Movido a {}", "MOVED_TO": "Movido a {}",
"FS_WATCHER_TOOLTIP": "Monitor de Sistema de Archivos (monitoreando "
"directorios activos)",
"COPIED_TO": "Copiado a {}", "COPIED_TO": "Copiado a {}",
"ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}", "ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}",
}, },
@@ -1300,11 +1521,13 @@ _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",
"SAVE": "Gardar", "SAVE": "Gardar",
"CREATE": "Crear", "CREATE": "Crear",
"CANCEL": "Cancelar",
"RENAME": "Renomear", "RENAME": "Renomear",
"COPY": "Copiar", "COPY": "Copiar",
"DELETE": "Eliminar", "DELETE": "Eliminar",
@@ -1416,6 +1639,83 @@ _UI_TEXTS = {
"MENU_SHOW_LAYOUTS": "Amosar Deseños", "MENU_SHOW_LAYOUTS": "Amosar Deseños",
"MENU_SHOW_HISTORY": "Amosar Historial", "MENU_SHOW_HISTORY": "Amosar Historial",
"MENU_SETTINGS": "Opcións", "MENU_SETTINGS": "Opcións",
"SETTINGS_GROUP_DUPLICATES": "Duplicados",
"MENU_DUPLICATES": "Duplicados",
"MENU_DETECT_CURRENT_SEARCH": "Detectar na busca actual",
"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_REVIEW_IGNORED": "Revisar ignorados",
"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)",
"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_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_TOOLTIP": "Selecciona o método para a detección "
"de duplicados.",
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
"METHOD_RESNET": "ResNet (Baseado en IA)",
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
"duplicados",
"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_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_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar "
"todos': {}",
"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_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_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",
"DUPLICATE_WHITELIST_EMPTY": "A lista branca está baleira. Por favor, "
"configúrea en Opcións.",
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
"DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.",
"DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.",
"DUPLICATE_STARTING": "Iniciando detección de duplicados...",
"DUPLICATE_PROGRESS": "Detección de duplicados: {message} ({current}/{total})",
"DUPLICATE_NONE_FOUND": "Non se atoparon duplicados.",
"DUPLICATE_FOUND_TITLE": "Duplicados Atopados",
"DUPLICATE_FOUND_MSG": "Atopáronse os seguintes duplicados:\n",
"DUPLICATE_FOUND_MORE": "... e {count} máis.",
"DUPLICATE_FINISHED": "Detección de duplicados finalizada.",
"DUPLICATE_MSG_HASHING": "Procesando {filename}",
"DUPLICATE_MSG_ANALYZING": "Analizando {filename}",
"DUPLICATE_MANAGER_TITLE": "Xestionar Imaxes Duplicadas",
"DUPLICATE_DELETE_LEFT": "Papeleira Esquerda",
"DUPLICATE_DELETE_RIGHT": "Papeleira Dereita",
"CONFIRM_TRASH_TITLE": "Mover á papeleira",
"CONFIRM_TRASH_TEXT": "Desexas mover esta imaxe á papeleira?",
"DUPLICATE_KEEP_BOTH": "Manter Ambas (Ignorar)",
"DUPLICATE_SKIP": "Omitir",
"DUPLICATE_REMOVE_IGNORED": "Eliminar de ignorados",
"DUPLICATE_INFO_FORMAT": "{size} - {width}x{height}",
"VIEWER_MENU_LINK_PANES": "Vincular Paneis",
"DUPLICATE_OPEN_COMPARISON": "Abrir Comparación",
"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",
@@ -1477,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:",
@@ -1616,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"],
@@ -1652,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",
@@ -1687,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: {}",
@@ -1750,6 +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_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",
@@ -1764,12 +2076,35 @@ _UI_TEXTS = {
"ERROR_MOVE_FILE": "Non se puido mover o ficheiro: {}", "ERROR_MOVE_FILE": "Non se puido mover o ficheiro: {}",
"ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}", "ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}",
"MOVED_TO": "Movido a {}", "MOVED_TO": "Movido a {}",
"FS_WATCHER_TOOLTIP": "Monitor do Sistema de Ficheiros (monitoreando "
"directorios activos)",
"COPIED_TO": "Copiado a {}", "COPIED_TO": "Copiado a {}",
"ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}", "ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}",
} }
} }
# Determine which language to use for UI strings
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)
if lang == "system":
sys_lang = os.getenv("LANG")
if sys_lang:
# LANG is usually something like 'en_US.UTF-8'
lang = sys_lang[0:2].lower()
else:
lang = FALLBACK_LANGUAGE
# If the resolved language is not supported by our translation dictionaries,
# fallback to English.
return lang if lang in _UI_TEXTS else FALLBACK_LANGUAGE
CURRENT_LANGUAGE = _get_current_language()
class _UITextsProxy: class _UITextsProxy:
""" """
A proxy class to access UI strings from the _UI_TEXTS dictionary. A proxy class to access UI strings from the _UI_TEXTS dictionary.
@@ -1781,12 +2116,12 @@ class _UITextsProxy:
""" """
def __getattr__(self, name): def __getattr__(self, name):
# Get the dictionary for the current language, or fallback to the default. # Get the dictionary for the current language, or fallback to the default.
lang_texts = _UI_TEXTS.get(CURRENT_LANGUAGE, _UI_TEXTS[DEFAULT_LANGUAGE]) lang_texts = _UI_TEXTS.get(CURRENT_LANGUAGE, _UI_TEXTS[FALLBACK_LANGUAGE])
# Get the specific string. If not found in the current language, # Get the specific string. If not found in the current language,
# try the default language. # try the default language.
text = lang_texts.get(name) text = lang_texts.get(name)
if text is None: if text is None:
default_texts = _UI_TEXTS[DEFAULT_LANGUAGE] default_texts = _UI_TEXTS[FALLBACK_LANGUAGE]
# Return a placeholder if not found anywhere # Return a placeholder if not found anywhere
text = default_texts.get(name, f"_{name}_") text = default_texts.get(name, f"_{name}_")
return text return text

1247
duplicatecache.py Normal file

File diff suppressed because it is too large Load Diff

1106
duplicatedialog.py Normal file

File diff suppressed because it is too large Load Diff

277
filesystemwatcher.py Normal file
View File

@@ -0,0 +1,277 @@
"""
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
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
HAVE_WATCHDOG = True
except ImportError:
HAVE_WATCHDOG = False
from PySide6.QtCore import QObject, Signal, QTimer
from constants import IMAGE_EXTENSIONS
class FileSystemWatcher(QObject):
"""
Monitors file system events (created, deleted, modified) for specified directories.
Emits signals to notify the main application thread of changes.
"""
# Signals emitted to the rest of the application
# ---------------------------------------------
file_created = Signal(str)
file_deleted = Signal(str)
file_modified = Signal(str)
_file_modified_from_handler = Signal(str) # Internal signal from handler thread
file_moved = Signal(str, str)
monitoring_status_changed = Signal(bool) # New: Signal for monitoring status
directory_moved = Signal(str, str)
directory_modified = Signal(str) # For changes that might not be specific files
_modified_events_queue = {} # {path: QTimer}
"""Queue to manage debouncing of modification events."""
def __init__(self, parent=None):
"""
Initializes the FileSystemWatcher.
Args:
parent (QObject, optional): The parent object. Defaults to None.
"""
super().__init__(parent)
self._watched_directories = set()
self._debounce_interval = 500 # milliseconds
if HAVE_WATCHDOG:
self._observer = Observer()
self._event_handler = self._Handler(self)
self._observer.start()
else:
self._observer = None # Keep observer as None if watchdog is not available
# Connect the internal signal to the debouncing slot
if HAVE_WATCHDOG:
self._file_modified_from_handler.connect(self._on_file_modified_debounced)
def _on_file_modified_debounced(self, path):
"""
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
if path in self._modified_events_queue:
self._modified_events_queue[path].stop()
else:
# Ensure timer lives in the main thread (parent is self)
timer = QTimer(self)
timer.setSingleShot(True)
timer.setInterval(self._debounce_interval)
timer.timeout.connect(lambda p=path: self._emit_modified_after_debounce(p))
self._modified_events_queue[path] = timer
self._modified_events_queue[path].start()
def _emit_modified_after_debounce(self, path):
"""
Emits the file_modified signal after the debounce period.
Args:
path (str): The path of the modified file.
"""
self.file_modified.emit(path)
if path in self._modified_events_queue:
# Safely delete the QTimer object when done
self._modified_events_queue[path].deleteLater()
del self._modified_events_queue[path]
def add_path(self, path):
"""
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:
return
# Normalize and expand path to ensure consistent comparison
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
# 1. Check if path is already covered by an existing watch (exact or parent)
for watched in self._watched_directories:
if abs_path == watched:
return
parent_prefix = watched if watched.endswith(os.sep) else watched + os.sep
if abs_path.startswith(parent_prefix):
return # Path is a subdirectory of an already watched directory
old_monitoring_state = bool(self._watched_directories)
# 2. Check if this new path covers existing watches (is a parent of them)
# If so, consolidate them into this single parent watch
child_prefix = abs_path if abs_path.endswith(os.sep) else abs_path + os.sep
covered_children = [w for w in self._watched_directories
if w.startswith(child_prefix)]
try:
if covered_children:
self._observer.unschedule_all()
for child in covered_children:
self._watched_directories.remove(child)
self._watched_directories.add(abs_path)
for p in self._watched_directories:
self._observer.schedule(self._event_handler, p, recursive=True)
print(f"Consolidated monitoring at parent: {abs_path}")
else:
self._observer.schedule(self._event_handler, abs_path, recursive=True)
self._watched_directories.add(abs_path)
print(f"Monitoring: {abs_path}")
except Exception as e:
print(f"Error scheduling watchdog for {abs_path}: {e}")
return
if not old_monitoring_state and self._watched_directories:
self.monitoring_status_changed.emit(True)
def remove_path(self, path):
"""
Removes a directory from monitoring.
Args:
path (str): The directory path to stop monitoring.
"""
if not HAVE_WATCHDOG or self._observer is None:
return
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
if abs_path in self._watched_directories:
old_monitoring_state = bool(self._watched_directories)
self._observer.unschedule_all() # Simpler to unschedule all and re-add
self._watched_directories.remove(abs_path)
for p in list(self._watched_directories): # Iterate over a copy
self._observer.schedule(self._event_handler, p, recursive=True)
print(f"Stopped monitoring: {abs_path}")
if HAVE_WATCHDOG and old_monitoring_state and not self._watched_directories:
self.monitoring_status_changed.emit(False)
def clear_paths(self):
"""Clears all monitored paths."""
if not HAVE_WATCHDOG or not self._observer:
return
old_monitoring_state = bool(self._watched_directories)
self._observer.unschedule_all()
self._watched_directories.clear()
print("Cleared all monitored paths.")
if old_monitoring_state:
self.monitoring_status_changed.emit(False)
def stop(self):
"""
Stops the file system observer and cleans up active timers.
"""
if HAVE_WATCHDOG and self._observer:
self._observer.stop()
self._observer.join()
for timer in self._modified_events_queue.values():
timer.stop()
if HAVE_WATCHDOG:
print("FileSystemWatcher stopped.")
if HAVE_WATCHDOG:
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
file_modified_from_thread = Signal(str)
def __init__(self, watcher):
"""
Initializes the handler with a reference to the main watcher.
"""
super().__init__()
self.watcher = watcher
def on_created(self, event):
"""Called when a file or directory is created."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher.file_created.emit(event.src_path)
def on_deleted(self, event):
"""Called when a file or directory is deleted."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher.file_deleted.emit(event.src_path)
def on_moved(self, event):
"""Called when a file or directory is moved or renamed."""
if event.is_directory:
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.dest_path)
return
self.watcher.file_moved.emit(event.src_path, event.dest_path)
def on_closed(self, event):
"""Called when a file is closed."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher.file_modified.emit(event.src_path)
def on_modified(self, event):
"""Called when a file or directory is modified."""
if event.is_directory:
self.watcher.directory_modified.emit(event.src_path)
return
if self._is_image_file(event.src_path):
self.watcher._file_modified_from_handler.emit(event.src_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)
if path in self.watcher._modified_events_queue:
del self.watcher._modified_events_queue[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

View File

@@ -13,7 +13,7 @@ Classes:
import os import os
import logging import logging
import math import math
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt, QSize
from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform
from xmpmanager import XmpManager from xmpmanager import XmpManager
from constants import ( from constants import (
@@ -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,21 +689,36 @@ class ImageController(QObject):
if self.pixmap_original.isNull(): if self.pixmap_original.isNull():
return QPixmap() return QPixmap()
transform = QTransform().rotate(self.rotation) # Start with an identity transform
transformed_pixmap = self.pixmap_original.transformed( transform = QTransform()
transform,
Qt.SmoothTransformation
)
new_size = transformed_pixmap.size() * self.zoom_factor
scaled_pixmap = transformed_pixmap.scaled(new_size, Qt.KeepAspectRatio,
Qt.SmoothTransformation)
# Apply rotation
if self.rotation != 0:
transform.rotate(float(self.rotation))
# Apply flips
if self.flip_h: if self.flip_h:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(-1, 1)) transform.scale(-1, 1)
if self.flip_v: if self.flip_v:
scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(1, -1)) transform.scale(1, -1)
# Apply the cumulative transform to the original pixmap
transformed_pixmap = self.pixmap_original.transformed(
transform, Qt.TransformationMode.SmoothTransformation)
# Apply scaling (zoom) separately after rotation and flips,
# as scaling should be based on the *transformed* dimensions.
# This is important: if you scale before rotation, the scaling
# factors might be applied to the wrong axes.
if self.zoom_factor != 1.0:
new_size_f = transformed_pixmap.size() * self.zoom_factor
new_size = QSize(int(new_size_f.width()), int(new_size_f.height()))
scaled_pixmap = transformed_pixmap.scaled(
new_size, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
return scaled_pixmap return scaled_pixmap
else:
return transformed_pixmap
def rotate(self, angle): def rotate(self, angle):
""" """

View File

@@ -32,10 +32,12 @@ 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 constants import ( from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES, APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CACHE_MAX_RAM_BYTES,
APP_DATA_DIR, MIN_FREE_RAM_PERCENT, DISK_CACHE_MAX_BYTES,
HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES,
UITexts UITexts
@@ -132,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)
@@ -196,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:
@@ -265,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
@@ -283,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
@@ -332,9 +336,9 @@ 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()
self.wait()
def run(self): def run(self):
self.setPriority(QThread.IdlePriority) self.setPriority(QThread.IdlePriority)
@@ -379,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):
@@ -442,7 +447,6 @@ class CacheLoader(QThread):
self._mutex.lock() self._mutex.lock()
self._condition.wakeAll() self._condition.wakeAll()
self._mutex.unlock() self._mutex.unlock()
self.wait()
def run(self): def run(self):
self.setPriority(QThread.IdlePriority) self.setPriority(QThread.IdlePriority)
@@ -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:
@@ -558,12 +571,22 @@ class ThumbnailCache(QObject):
self._lmdb_env = None self._lmdb_env = None
def lmdb_close(self): def lmdb_close(self):
# Stop and wait for worker threads to ensure they are not accessing
# the LMDB environment while it's being closed.
if hasattr(self, '_cache_writer') and self._cache_writer: if hasattr(self, '_cache_writer') and self._cache_writer:
self._cache_writer.stop() self._cache_writer.stop()
while self._cache_writer.isRunning():
if QApplication.instance(): # Check if QApplication is still valid
QApplication.processEvents() # Keep UI responsive
QThread.msleep(50)
self._cache_writer = None self._cache_writer = None
if hasattr(self, '_cache_loader') and self._cache_loader: if hasattr(self, '_cache_loader') and self._cache_loader:
self._cache_loader.stop() self._cache_loader.stop()
while self._cache_loader.isRunning():
if QApplication.instance(): # Check if QApplication is still valid
QApplication.processEvents() # Keep UI responsive
QThread.msleep(50)
self._cache_loader = None self._cache_loader = None
self._loading_set.clear() self._loading_set.clear()
self._futures.clear() self._futures.clear()
@@ -644,14 +667,75 @@ class ThumbnailCache(QObject):
self._cache_lock.unlock() self._cache_lock.unlock()
def _ensure_cache_limit(self): def _ensure_cache_limit(self):
"""Enforces cache size limit by evicting oldest entries. """Enforces cache size limit and reacts to system memory pressure.
Must be called with a write lock held.""" Must be called with a write lock held."""
# Safety limit: 512MB for thumbnails in RAM to prevent system freeze
MAX_RAM_BYTES = 512 * 1024 * 1024
# 1. Enforce internal limits using tiered strategy
while len(self._thumbnail_cache) > 0 and ( while len(self._thumbnail_cache) > 0 and (
len(self._thumbnail_cache) >= CACHE_MAX_SIZE or len(self._thumbnail_cache) >= CACHE_MAX_SIZE or
self._cache_bytes_size > MAX_RAM_BYTES): self._cache_bytes_size > CACHE_MAX_RAM_BYTES):
self._evict_tiered()
# Check system-wide memory pressure (Low RAM fallback)
try:
import psutil
mem = psutil.virtual_memory()
if (mem.available / mem.total) * 100 < MIN_FREE_RAM_PERCENT:
logger.warning(f"Low system memory detected "
f"(< {MIN_FREE_RAM_PERCENT}%). "
f"Applying aggressive tiered pruning.")
# Strategy: first clear ALL cached high-res tiers to free space quickly
# while keeping the 128px grid thumbnails intact.
for tier in [512, 256]:
self._prune_tier(tier)
# Re-check if pressure relieved
mem = psutil.virtual_memory()
if (mem.available / mem.total) * 100 >= MIN_FREE_RAM_PERCENT:
return
# If still under pressure, remove oldest 10% of remaining entries
items_to_prune = max(1, len(self._thumbnail_cache) // 10)
for _ in range(items_to_prune):
if not self._thumbnail_cache:
break
self._evict_oldest_path_entirely()
except (ImportError, Exception):
pass
def _evict_tiered(self):
"""
Removes content from cache targeting larger tiers first from the oldest entries.
Must be called with write lock held.
"""
# We check the oldest 100 entries for large tiers to avoid O(N) scans
# on every call while still being very effective for LRU behavior.
check_limit = 100
for tier in [512, 256]:
count = 0
for path, sizes in self._thumbnail_cache.items():
if tier in sizes:
img, _ = sizes.pop(tier)
if img:
self._cache_bytes_size -= img.sizeInBytes()
return
count += 1
if count >= check_limit:
break
self._evict_oldest_path_entirely()
def _prune_tier(self, tier):
"""Removes all thumbnails of a specific tier from cache to free RAM quickly."""
for path, sizes in self._thumbnail_cache.items():
if tier in sizes:
img, _ = sizes.pop(tier)
if img:
self._cache_bytes_size -= img.sizeInBytes()
def _evict_oldest_path_entirely(self):
"""Removes the oldest cache entry completely. Must be called with write lock."""
oldest_path = next(iter(self._thumbnail_cache)) oldest_path = next(iter(self._thumbnail_cache))
cached_sizes = self._thumbnail_cache.pop(oldest_path) cached_sizes = self._thumbnail_cache.pop(oldest_path)
for img, _ in cached_sizes.values(): for img, _ in cached_sizes.values():
@@ -661,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
@@ -787,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():
@@ -1129,6 +1235,12 @@ class ThumbnailCache(QObject):
return None return None
if not img.save(buf, "PNG"): if not img.save(buf, "PNG"):
# libpng errors (like "Incorrect data in iCCP") can cause save() topi
# fail.
# Converting to a standard format strips problematic metadata/profiles.
ba.clear()
buf.seek(0)
if not img.convertToFormat(QImage.Format_ARGB32).save(buf, "PNG"):
logger.error("Failed to save image to buffer") logger.error("Failed to save image to buffer")
return None return None
return ba.data() return ba.data()
@@ -1251,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)
@@ -1322,8 +1435,18 @@ class ThumbnailGenerator(QThread):
# The signal/slot mechanism handles thread safety automatically. # The signal/slot mechanism handles thread safety automatically.
emitter.progress_tick.connect(on_tick, Qt.QueuedConnection) emitter.progress_tick.connect(on_tick, Qt.QueuedConnection)
started_count = 0 # Process in batches to avoid saturating the global thread pool queue.
for path in self.paths: # This allows the application to respond to stop() signals almost immediately.
batch_size = max(4, pool.maxThreadCount() * 2)
for i in range(0, len(self.paths), batch_size):
if self._abort:
break
batch_slice = self.paths[i : i + batch_size]
started_in_batch = 0
for path in batch_slice:
if self._abort: if self._abort:
break break
runnable = ScannerWorker(self.cache, path, target_sizes=[self.size], runnable = ScannerWorker(self.cache, path, target_sizes=[self.size],
@@ -1332,17 +1455,18 @@ class ThumbnailGenerator(QThread):
runnable.setAutoDelete(False) runnable.setAutoDelete(False)
self._workers_mutex.lock() self._workers_mutex.lock()
if self._abort:
self._workers_mutex.unlock()
break
self._workers.append(runnable) self._workers.append(runnable)
self._workers_mutex.unlock() self._workers_mutex.unlock()
pool.start(runnable) pool.start(runnable)
started_count += 1 started_in_batch += 1
if started_count > 0: if started_in_batch > 0:
sem.acquire(started_count) # Wait for the current batch to finish before queuing more
sem.acquire(started_in_batch)
self._workers_mutex.lock()
self._workers.clear()
self._workers_mutex.unlock()
self._workers_mutex.lock() self._workers_mutex.lock()
self._workers.clear() self._workers.clear()
@@ -1365,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
@@ -1728,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)
@@ -1752,6 +1877,10 @@ class ImageScanner(QThread):
batch.append(r.result) batch.append(r.result)
self.count += 1 self.count += 1
images_loaded += 1 images_loaded += 1
# Emit progress every time an image is loaded
if len(self.all_files) > 0:
percent = int((self.count / len(self.all_files)) * 100)
self.progress_percent.emit(percent)
# Clean up runnables # Clean up runnables
runnables.clear() runnables.clear()
@@ -1796,12 +1925,11 @@ class ImageScanner(QThread):
"scan_batch_size"])) "scan_batch_size"]))
return return
if self.count % 10 == 0: # Update progress less frequently # Emit progress message less frequently, e.g., every 50 images or at batch
self.progress_msg.emit( # end
UITexts.LOADING_SCAN.format(self.count, len(self.all_files))) if self.count % 50 == 0 or images_loaded >= to_load:
if len(self.all_files) > 0: self.progress_msg.emit(UITexts.LOADING_SCAN.format(
percent = int((self.count / len(self.all_files)) * 100) self.count, len(self.all_files)))
self.progress_percent.emit(percent)
self.index = len(self.all_files) self.index = len(self.all_files)
if batch: if batch:

View File

@@ -26,6 +26,7 @@ from PySide6.QtCore import (
Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF, Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF,
QThread, QObject QThread, QObject
) )
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from constants import ( from constants import (
APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS, APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS,
@@ -238,7 +239,10 @@ class FastTagManager:
current_path = controller.get_current_path() if controller else None current_path = controller.get_current_path() if controller else None
if not current_path: if not current_path:
return return
try:
controller.toggle_tag(tag_name, is_checked) controller.toggle_tag(tag_name, is_checked)
except Exception as e:
QMessageBox.critical(self.viewer, UITexts.ERROR, str(e))
self.viewer.update_status_bar() self.viewer.update_status_bar()
if self.main_win: if self.main_win:
if is_checked: if is_checked:
@@ -419,11 +423,22 @@ class FaceCanvas(QLabel):
self.edit_handle = None self.edit_handle = None
self.edit_start_rect = QRect() self.edit_start_rect = QRect()
self.resize_margin = 8 self.resize_margin = 8
# Zoom indicator
self.zoom_indicator_point = None
self.zoom_indicator_timer = QTimer(self)
self.zoom_indicator_timer.setSingleShot(True)
self.zoom_indicator_timer.setInterval(500) # Show for 500ms
self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator)
self.crop_rect = QRect() self.crop_rect = QRect()
self.crop_handle = None self.crop_handle = None
self.crop_start_pos = QPoint() self.crop_start_pos = QPoint()
self.crop_start_rect = QRect() self.crop_start_rect = QRect()
def _clear_zoom_indicator(self):
self.zoom_indicator_point = None
self.update()
def map_from_source(self, face_data): def map_from_source(self, face_data):
"""Maps original normalized face data to current canvas QRect.""" """Maps original normalized face data to current canvas QRect."""
nx = face_data.get('x', 0) nx = face_data.get('x', 0)
@@ -623,6 +638,18 @@ class FaceCanvas(QLabel):
painter.drawRect(pt.x() - offset, pt.y() - offset, painter.drawRect(pt.x() - offset, pt.y() - offset,
handle_size, handle_size) handle_size, handle_size)
# Draw zoom indicator
if self.zoom_indicator_point:
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
painter.drawLine(self.zoom_indicator_point.x() - 10,
self.zoom_indicator_point.y(),
self.zoom_indicator_point.x() + 10,
self.zoom_indicator_point.y())
painter.drawLine(self.zoom_indicator_point.x(),
self.zoom_indicator_point.y() - 10,
self.zoom_indicator_point.x(),
self.zoom_indicator_point.y() + 10)
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."""
if not self.controller.show_faces: if not self.controller.show_faces:
@@ -990,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:
@@ -1122,18 +1153,62 @@ class ZoomManager(QObject):
super().__init__(viewer) super().__init__(viewer)
self.viewer = viewer self.viewer = viewer
def zoom(self, factor, reset=False): def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None):
"""Applies zoom to the image.""" """Applies zoom to the image, centering on focus_point if provided."""
if not self.viewer.controller or \
self.viewer.controller.pixmap_original.isNull():
return
c_point = None
if reset: if reset:
self.viewer.controller.zoom_factor = 1.0 self.viewer.controller.zoom_factor = 1.0
self.viewer.update_view(resize_win=True) self.viewer.update_view(resize_win=True)
if self.viewer.canvas:
c_point = self.viewer.canvas.rect().center()
elif absolute_factor is not None: # New: set absolute zoom factor
self.viewer.controller.zoom_factor = absolute_factor
# Don't resize window for sync zoom
self.viewer.update_view(resize_win=False)
if focus_point is not None and self.viewer.canvas:
scroll_area = self.viewer.scroll_area
viewport = scroll_area.viewport()
v_point = viewport.mapFrom(self.viewer, focus_point)
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
else: else:
# 1. Determine focus point in viewport coordinates
scroll_area = self.viewer.scroll_area
viewport = scroll_area.viewport()
if focus_point is None:
v_point = viewport.rect().center()
else:
# focus_point is relative to the self.viewer widget
# (ImageViewer or ImagePane)
v_point = viewport.mapFrom(self.viewer, focus_point)
# 2. Map focus point to canvas coordinates before zoom
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
self.viewer.controller.zoom_factor *= factor self.viewer.controller.zoom_factor *= factor
self.viewer.update_view(resize_win=True) # Apply update (this resizes the canvas)
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
# 3. Adjust scrollbars to maintain pixel under cursor
scroll_area.horizontalScrollBar().setValue(
int(c_point.x() * factor - v_point.x()))
scroll_area.verticalScrollBar().setValue(
int(c_point.y() * factor - v_point.y()))
# Notify the main window that the image (and possibly index) has changed # Notify the main window that the image (and possibly index) has changed
# so it can update its selection. # so it can update its selection.
self.viewer.index_changed.emit(self.viewer.controller.index) self.viewer.index_changed.emit(self.viewer.controller.index)
if focus_point is not None and self.viewer.canvas:
self.viewer.canvas.zoom_indicator_point = c_point
self.viewer.canvas.zoom_indicator_timer.start()
self.viewer.canvas.update()
self.zoomed.emit(self.viewer.controller.zoom_factor) self.zoomed.emit(self.viewer.controller.zoom_factor)
if hasattr(self.viewer, 'sync_filmstrip_selection'): if hasattr(self.viewer, 'sync_filmstrip_selection'):
self.viewer.sync_filmstrip_selection(self.viewer.controller.index) self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
@@ -1645,16 +1720,23 @@ class ImageViewer(QWidget):
if pane != self.active_pane: if pane != self.active_pane:
pane.controller.zoom_factor = factor pane.controller.zoom_factor = factor
pane.update_view(resize_win=False) pane.update_view(resize_win=False)
# Re-apply relative scroll after zoom changes bounds # Re-apply relative scroll after zoom changes bounds
# We defer this to the next event loop iteration to ensure
# that QScrollArea has updated its scrollbar maximums.
if self.active_pane: if self.active_pane:
h_bar = self.active_pane.scroll_area.horizontalScrollBar() h_bar = self.active_pane.scroll_area.horizontalScrollBar()
v_bar = self.active_pane.scroll_area.verticalScrollBar() v_bar = self.active_pane.scroll_area.verticalScrollBar()
h_max = h_bar.maximum() h_max = h_bar.maximum()
v_max = v_bar.maximum() v_max = v_bar.maximum()
if h_max > 0 or v_max > 0:
x_pct = h_bar.value() / h_max if h_max > 0 else 0 x_pct = h_bar.value() / h_max if h_max > 0 else 0
y_pct = v_bar.value() / v_max if v_max > 0 else 0 y_pct = v_bar.value() / v_max if v_max > 0 else 0
pane.set_scroll_relative(x_pct, y_pct)
for pane in self.panes:
if pane != self.active_pane:
QTimer.singleShot(
0, lambda p=pane, x=x_pct,
y=y_pct: p.set_scroll_relative(x, y))
def update_grid_layout(self): def update_grid_layout(self):
# Clear layout # Clear layout
@@ -1693,6 +1775,9 @@ class ImageViewer(QWidget):
for i in range(count - current_panes): for i in range(count - current_panes):
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:
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)
@@ -1710,10 +1795,13 @@ 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()
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."""
self.panes_linked = not self.panes_linked self.panes_linked = not self.panes_linked
if self.panes_linked and self.active_pane:
self._sync_zoom(self.active_pane.controller.zoom_factor)
self.update_status_bar() self.update_status_bar()
def update_highlight(self): def update_highlight(self):
@@ -1731,6 +1819,9 @@ class ImageViewer(QWidget):
def reset_inactivity_timer(self): def reset_inactivity_timer(self):
"""Resets the inactivity timer and restores controls visibility.""" """Resets the inactivity timer and restores controls visibility."""
if self.active_pane and self.active_pane.canvas:
self.active_pane.canvas._clear_zoom_indicator()
if self.isFullScreen(): if self.isFullScreen():
self.unsetCursor() self.unsetCursor()
if self.main_win and self.main_win.show_viewer_status_bar: if self.main_win and self.main_win.show_viewer_status_bar:
@@ -2110,7 +2201,11 @@ class ImageViewer(QWidget):
available_h -= self.status_bar_container.sizeHint().height() available_h -= self.status_bar_container.sizeHint().height()
should_resize = True should_resize = True
self.zoom_manager.calculate_initial_zoom(available_w, available_h, if self.panes_linked and self.active_pane and pane != self.active_pane:
# Inherit zoom from active pane instead of recalculating
pane.controller.zoom_factor = self.active_pane.controller.zoom_factor
else:
pane.zoom_manager.calculate_initial_zoom(available_w, available_h,
self.isFullScreen()) self.isFullScreen())
self.update_view(resize_win=should_resize) self.update_view(resize_win=should_resize)
@@ -2744,8 +2839,11 @@ class ImageViewer(QWidget):
self.main_win.face_names_history = updated_history self.main_win.face_names_history = updated_history
# Save changes and add new tag # Save changes and add new tag
try:
self.controller.save_faces() self.controller.save_faces()
self.controller.toggle_tag(new_full_tag, True) self.controller.toggle_tag(new_full_tag, True)
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
if self.canvas: if self.canvas:
self.canvas.update() self.canvas.update()
@@ -3027,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
@@ -3043,7 +3143,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_pet_detection(self): def run_pet_detection(self):
"""Runs pet detection on the current image.""" """Runs pet detection on the current image."""
@@ -3084,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
@@ -3099,7 +3205,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def run_body_detection(self): def run_body_detection(self):
"""Runs body detection on the current image.""" """Runs body detection on the current image."""
@@ -3142,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
@@ -3157,7 +3269,10 @@ class ImageViewer(QWidget):
self.canvas.update() self.canvas.update()
if added_count > 0: if added_count > 0:
try:
self.controller.save_faces() self.controller.save_faces()
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
def toggle_filmstrip(self): def toggle_filmstrip(self):
"""Shows or hides the filmstrip widget.""" """Shows or hides the filmstrip widget."""
@@ -3219,17 +3334,19 @@ class ImageViewer(QWidget):
self.reset_inactivity_timer() self.reset_inactivity_timer()
if event.modifiers() & Qt.ControlModifier: if event.modifiers() & Qt.ControlModifier:
# Zoom with Ctrl + Wheel # Zoom with Ctrl + Wheel
focus_pos = event.position().toPoint()
if event.angleDelta().y() > 0: if event.angleDelta().y() > 0:
self.zoom_manager.zoom(1.1) self.zoom_manager.zoom(1.1, focus_point=focus_pos)
else: else:
self.zoom_manager.zoom(0.9) self.zoom_manager.zoom(0.9, focus_point=focus_pos)
else: else:
# Navigate next/previous based on configurable speed # Navigate next/previous based on configurable speed
speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT) speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT)
# 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()
@@ -3336,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
@@ -3360,13 +3478,14 @@ class ImageViewer(QWidget):
""" """
if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None: if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None:
try: try:
subprocess.Popen([ msg = QDBusMessage.createMethodCall(
"dbus-send", "--session", "org.freedesktop.ScreenSaver",
"--dest=org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver", "/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver.UnInhibit", "org.freedesktop.ScreenSaver",
f"uint32:{self.inhibit_cookie}" "UnInhibit"
]) )
msg.setArguments([self.inhibit_cookie])
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
self.inhibit_cookie = None self.inhibit_cookie = None
except Exception as e: except Exception as e:
print(f"{UITexts.ERROR} uninhibiting: {e}") print(f"{UITexts.ERROR} uninhibiting: {e}")

View File

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

View File

@@ -12,7 +12,7 @@ Classes:
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QFormLayout, QDialogButtonBox, QApplication QFormLayout, QDialogButtonBox, QApplication, QToolBar, QAbstractItemView
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QImageReader, QIcon, QColor QImageReader, QIcon, QColor
@@ -76,6 +76,8 @@ class PropertiesDialog(QDialog):
self.setWindowTitle(UITexts.PROPERTIES_TITLE) self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else [] self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating self._initial_rating = initial_rating
self.original_xattrs = {}
self.original_exif = {}
self.loader = None self.loader = None
self.resize(400, 500) self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
@@ -136,21 +138,25 @@ class PropertiesDialog(QDialog):
meta_widget = QWidget() meta_widget = QWidget()
meta_layout = QVBoxLayout(meta_widget) meta_layout = QVBoxLayout(meta_widget)
self.meta_toolbar = QToolBar()
self._setup_table_toolbar(
self.meta_toolbar, self.on_add_meta, self.on_delete_meta,
self.on_delete_all_meta, self.on_save_meta, self.on_cancel_meta)
meta_layout.addWidget(self.meta_toolbar)
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(2) self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
QHeaderView.ResizeToContents)
self.table.setColumnWidth(0, self.width() * 0.4)
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True) self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QTableWidget.DoubleClicked | self.table.setEditTriggers(QAbstractItemView.DoubleClicked |
QTableWidget.EditKeyPressed | QAbstractItemView.EditKeyPressed |
QTableWidget.SelectedClicked) QAbstractItemView.AnyKeyPressed)
self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.table.itemChanged.connect(self.on_item_changed)
self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu) self.table.customContextMenuRequested.connect(self.show_context_menu)
@@ -164,6 +170,12 @@ class PropertiesDialog(QDialog):
exif_widget = QWidget() exif_widget = QWidget()
exif_layout = QVBoxLayout(exif_widget) exif_layout = QVBoxLayout(exif_widget)
self.exif_toolbar = QToolBar()
self._setup_table_toolbar(
self.exif_toolbar, self.on_add_exif, self.on_delete_exif,
self.on_delete_all_exif, self.on_save_exif, self.on_cancel_exif)
exif_layout.addWidget(self.exif_toolbar)
self.exif_table = QTableWidget() self.exif_table = QTableWidget()
# This table will display EXIF/XMP/IPTC data. # This table will display EXIF/XMP/IPTC data.
# Reading this data involves opening the file with exiv2, which is a disk read. # Reading this data involves opening the file with exiv2, which is a disk read.
@@ -174,14 +186,15 @@ class PropertiesDialog(QDialog):
# without a significant architectural change (e.g., a dedicated metadata DB). # without a significant architectural change (e.g., a dedicated metadata DB).
self.exif_table.setColumnCount(2) self.exif_table.setColumnCount(2)
self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.exif_table.horizontalHeader().setSectionResizeMode( self.exif_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
0, QHeaderView.ResizeToContents) self.exif_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.exif_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.exif_table.verticalHeader().setVisible(False) self.exif_table.verticalHeader().setVisible(False)
self.exif_table.setAlternatingRowColors(True) self.exif_table.setAlternatingRowColors(True)
self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers) self.exif_table.setEditTriggers(QAbstractItemView.DoubleClicked |
QAbstractItemView.EditKeyPressed |
QAbstractItemView.AnyKeyPressed)
self.exif_table.setSelectionBehavior(QTableWidget.SelectRows) self.exif_table.setSelectionBehavior(QTableWidget.SelectRows)
self.exif_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu) self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu)
# This is a disk read. # This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
@@ -204,6 +217,100 @@ 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):
if self.loader and self.loader.isRunning():
self.loader.stop()
super().done(r)
def closeEvent(self, event): def closeEvent(self, event):
if self.loader and self.loader.isRunning(): if self.loader and self.loader.isRunning():
self.loader.stop() self.loader.stop()
@@ -227,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)
@@ -237,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
@@ -298,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()
@@ -305,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()
@@ -323,25 +432,6 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False) self.exif_table.blockSignals(False)
def on_item_changed(self, item):
"""
Slot that triggers when an item in the metadata table is changed.
Args:
item (QTableWidgetItem): The item that was changed.
"""
if item.column() == 1:
key = self.table.item(item.row(), 0).text()
val = item.text()
# Treat empty or whitespace-only values as removal to match previous
# behavior
val_to_set = val if val.strip() else None
try:
XattrManager.set_attribute(self.path, key, val_to_set)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
def show_context_menu(self, pos): def show_context_menu(self, pos):
""" """
Displays a context menu in the metadata table. Displays a context menu in the metadata table.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bagheeraview" name = "bagheeraview"
version = "0.9.14" version = "0.9.26"
authors = [ authors = [
{ name = "Ignacio Serantes" } { name = "Ignacio Serantes" }
] ]
@@ -23,6 +23,9 @@ dependencies = [
"PySide6", "PySide6",
"lmdb", "lmdb",
"exiv2", "exiv2",
"psutil",
"watchdog",
"imagehash",
"mediapipe", "mediapipe",
"face_recognition", "face_recognition",
"face_recognition_models", "face_recognition_models",
@@ -53,8 +56,11 @@ py-modules = [
"imagecontroller", "imagecontroller",
"metadatamanager", "metadatamanager",
"propertiesdialog", "propertiesdialog",
"thumbnailwidget", #"thumbnailwidget",
"duplicatecache",
"duplicatedialog",
"widgets", "widgets",
"filesystemwatcher",
"xmpmanager", "xmpmanager",
"utils" "utils"
] ]

View File

@@ -1,6 +1,9 @@
PySide6 PySide6
lmdb lmdb
exiv2 exiv2
psutil
watchdog
imagehash
mediapipe mediapipe
face_recognition face_recognition
face_recognition_models face_recognition_models

View File

@@ -14,12 +14,13 @@ import os
import shutil import shutil
import urllib.request import urllib.request
from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QColor, QIcon, QFont from PySide6.QtGui import QColor, QIcon, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox, QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox,
QTabWidget, QVBoxLayout, QWidget QTabWidget, QVBoxLayout, QWidget, QSlider, QFileDialog, QListWidget,
QListWidgetItem, QProgressBar
) )
from constants import ( from constants import (
APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR, APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR,
@@ -27,7 +28,7 @@ from constants import (
FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL,
AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR, AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR,
MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL,
HAVE_BAGHEERASEARCH_LIB, HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS,
SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_FILENAME_LINES_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT,
THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT,
@@ -36,10 +37,74 @@ from constants import (
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT,
THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT,
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT, VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT,
UITexts, save_app_config UITexts, save_app_config, HAVE_DUPLICATE_RESNET_LIBS, HAVE_IMAGEHASH
) )
class DuplicateFileCounter(QThread):
"""Thread to count images in whitelist/blacklist without freezing UI."""
count_updated = Signal(int)
finished = Signal(int)
def __init__(self, whitelist, blacklist, extensions):
super().__init__()
self.whitelist = whitelist
self.blacklist = blacklist
self.extensions = extensions
self._abort = False
def stop(self):
self._abort = True
self.wait()
def run(self):
count = 0
for root_path in self.whitelist:
if self._abort:
break
if not os.path.exists(root_path):
continue
for root, dirs, files in os.walk(root_path):
if self._abort:
break
abs_root = os.path.abspath(root)
dirs[:] = [d for d in dirs
if os.path.join(abs_root, d) not in self.blacklist]
if abs_root in self.blacklist:
continue
for f in files:
if self._abort:
break
if os.path.splitext(f)[1].lower() in self.extensions:
if os.path.join(abs_root, f) not in self.blacklist:
count += 1
self.count_updated.emit(count)
self.finished.emit(count)
class PathListWidget(QListWidget):
"""A QListWidget that accepts folder drops from external file explorers."""
def __init__(self, add_callback, parent=None):
super().__init__(parent)
self.add_callback = add_callback
self.setAcceptDrops(True)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
for url in event.mimeData().urls():
path = url.toLocalFile()
if path and os.path.isdir(path):
self.add_callback(self, path)
event.acceptProposedAction()
class ModelDownloader(QThread): class ModelDownloader(QThread):
"""A thread to download the MediaPipe model file without freezing the UI.""" """A thread to download the MediaPipe model file without freezing the UI."""
download_complete = Signal(bool, str) # success (bool), message (str) download_complete = Signal(bool, str) # success (bool), message (str)
@@ -93,6 +158,7 @@ class SettingsDialog(QDialog):
self.current_thumbs_tooltip_bg_color = THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT self.current_thumbs_tooltip_bg_color = THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT
self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT
self.downloader_thread = None self.downloader_thread = None
self.counter_thread = None
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -112,6 +178,9 @@ class SettingsDialog(QDialog):
scanner_tab = QWidget() scanner_tab = QWidget()
scanner_layout = QVBoxLayout(scanner_tab) scanner_layout = QVBoxLayout(scanner_tab)
duplicates_tab = QWidget()
duplicates_layout = QVBoxLayout(duplicates_tab)
# --- Thumbnails Tab --- # --- Thumbnails Tab ---
mru_tags_layout = QHBoxLayout() mru_tags_layout = QHBoxLayout()
@@ -344,6 +413,142 @@ class SettingsDialog(QDialog):
scanner_layout.addLayout(scan_full_on_start_layout) scanner_layout.addLayout(scan_full_on_start_layout)
scanner_layout.addStretch() scanner_layout.addStretch()
# --- Duplicates Tab ---
if not HAVE_IMAGEHASH:
warning_lbl = QLabel(UITexts.SETTINGS_DUPLICATE_MISSING_LIBS)
warning_lbl.setStyleSheet("color: #e74c3c; font-weight: bold;")
warning_lbl.setWordWrap(True)
duplicates_layout.addWidget(warning_lbl)
method_layout = QHBoxLayout()
method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL)
self.duplicate_method_combo = QComboBox()
self.duplicate_method_combo.addItem(
UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing")
self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet")
self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH)
if not HAVE_DUPLICATE_RESNET_LIBS:
resnet_idx = self.duplicate_method_combo.findData("resnet")
if resnet_idx != -1:
item = self.duplicate_method_combo.model().item(resnet_idx)
if item:
item.setEnabled(False)
method_layout.addWidget(method_label)
method_layout.addWidget(self.duplicate_method_combo)
method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
self.duplicate_method_combo.setToolTip(
UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
duplicates_layout.addLayout(method_layout)
threshold_layout = QHBoxLayout()
threshold_label = QLabel(UITexts.SETTINGS_DUPLICATE_THRESHOLD_LABEL)
self.duplicate_threshold_slider = QSlider(Qt.Horizontal)
self.duplicate_threshold_slider.setRange(50, 100)
self.duplicate_threshold_value_label = QLabel("0%")
self.duplicate_threshold_slider.setEnabled(HAVE_IMAGEHASH)
self.duplicate_threshold_value_label.setFixedWidth(40)
threshold_layout.addWidget(threshold_label)
threshold_layout.addWidget(self.duplicate_threshold_slider)
threshold_layout.addWidget(self.duplicate_threshold_value_label)
threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
self.duplicate_threshold_slider.setToolTip(
UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
self.duplicate_threshold_slider.valueChanged.connect(
lambda v: self.duplicate_threshold_value_label.setText(f"{v}%"))
def create_path_list_ui(label_text, tooltip):
container = QWidget()
v_layout = QVBoxLayout(container)
v_layout.setContentsMargins(0, 0, 0, 0)
v_layout.addWidget(QLabel(label_text))
h_layout = QHBoxLayout()
lst = PathListWidget(self._add_path_to_list)
lst.setToolTip(tooltip)
lst.setMinimumHeight(100)
h_layout.addWidget(lst)
btn_vbox = QVBoxLayout()
add_btn = QPushButton()
add_btn.setIcon(QIcon.fromTheme("list-add"))
add_btn.setFixedWidth(30)
rem_btn = QPushButton()
rem_btn.setIcon(QIcon.fromTheme("list-remove"))
rem_btn.setFixedWidth(30)
btn_vbox.addWidget(add_btn)
btn_vbox.addWidget(rem_btn)
btn_vbox.addStretch()
h_layout.addLayout(btn_vbox)
v_layout.addLayout(h_layout)
return container, lst, add_btn, rem_btn
# Whitelist
wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui(
UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL,
UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP)
wl_add.clicked.connect(self.add_whitelist_path)
wl_rem.clicked.connect(self.remove_whitelist_path)
duplicates_layout.addWidget(wl_cont)
# Blacklist
bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui(
UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL,
UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP)
bl_add.clicked.connect(self.add_blacklist_path)
bl_rem.clicked.connect(self.remove_blacklist_path)
duplicates_layout.addWidget(bl_cont)
# Image Count Layout
count_layout = QHBoxLayout()
self.duplicate_scan_count_label = QLabel()
self.duplicate_scan_count_label.setStyleSheet(
"color: #3498db; font-weight: bold;")
self.duplicate_scan_progress = QProgressBar()
self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode
self.duplicate_scan_progress.setFixedHeight(10)
self.duplicate_scan_progress.setFixedWidth(100)
self.duplicate_scan_progress.hide()
count_layout.addWidget(self.duplicate_scan_count_label)
count_layout.addWidget(self.duplicate_scan_progress)
count_layout.addStretch()
duplicates_layout.addLayout(count_layout)
# Timer for debounced count update
self.count_update_timer = QTimer(self)
self.count_update_timer.setSingleShot(True)
self.count_update_timer.setInterval(500)
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().rowsRemoved.connect(
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.setToolTip(
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP)
duplicates_layout.addWidget(self.default_delete_to_trash_checkbox)
duplicates_layout.addLayout(threshold_layout)
self.duplicate_confirm_delete_checkbox = QCheckBox(
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.addStretch()
# --- Faces & People Tab --- # --- Faces & People Tab ---
faces_tab = QWidget() faces_tab = QWidget()
faces_layout = QVBoxLayout(faces_tab) faces_layout = QVBoxLayout(faces_tab)
@@ -409,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)
@@ -465,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)
@@ -512,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)
@@ -558,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)
@@ -605,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)
@@ -645,6 +874,7 @@ class SettingsDialog(QDialog):
tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER) tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER)
tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS) tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_AREAS)
tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER) tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER)
tabs.addTab(duplicates_tab, UITexts.SETTINGS_GROUP_DUPLICATES)
# --- Button Box --- # --- Button Box ---
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
@@ -705,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(
@@ -737,6 +973,31 @@ class SettingsDialog(QDialog):
show_tags = APP_CONFIG.get("thumbnails_show_tags", True) show_tags = APP_CONFIG.get("thumbnails_show_tags", True)
filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom") filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom")
duplicate_method = APP_CONFIG.get("duplicate_method", "histogram_hashing")
method_idx = self.duplicate_method_combo.findData(duplicate_method)
if method_idx != -1:
self.duplicate_method_combo.setCurrentIndex(method_idx)
duplicate_threshold = APP_CONFIG.get(
"duplicate_threshold", SCANNER_SETTINGS_DEFAULTS["duplicate_threshold"])
self.duplicate_threshold_slider.setValue(duplicate_threshold)
self.duplicate_threshold_value_label.setText(f"{duplicate_threshold}%")
default_delete_to_trash = APP_CONFIG.get("default_delete_to_trash", True)
self.default_delete_to_trash_checkbox.setChecked(default_delete_to_trash)
duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True)
self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete)
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()]:
self._add_path_to_list(self.duplicate_whitelist_list, p)
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()]:
self._add_path_to_list(self.duplicate_blacklist_list, p)
self.scan_max_level_spin.setValue(scan_max_level) self.scan_max_level_spin.setValue(scan_max_level)
self.scan_batch_size_spin.setValue(scan_batch_size) self.scan_batch_size_spin.setValue(scan_batch_size)
self.threads_spin.setValue(scan_threads) self.threads_spin.setValue(scan_threads)
@@ -795,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)
@@ -821,6 +1088,7 @@ class SettingsDialog(QDialog):
self.filmstrip_pos_combo.setCurrentText( self.filmstrip_pos_combo.setCurrentText(
pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM)) pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM))
self.update_mediapipe_status() self.update_mediapipe_status()
self.update_duplicate_scan_count()
def set_button_color(self, color_str): def set_button_color(self, color_str):
"""Sets the background color of the button and stores the value.""" """Sets the background color of the button and stores the value."""
@@ -979,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)
@@ -1050,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
@@ -1068,6 +1343,19 @@ class SettingsDialog(QDialog):
APP_CONFIG["thumbnails_show_rating"] = self.show_rating_check.isChecked() APP_CONFIG["thumbnails_show_rating"] = self.show_rating_check.isChecked()
APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked() APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked()
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_threshold"] = self.duplicate_threshold_slider.value()
APP_CONFIG["default_delete_to_trash"] = \
self.default_delete_to_trash_checkbox.isChecked()
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)
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["viewer_auto_resize_window"] = \ APP_CONFIG["viewer_auto_resize_window"] = \
self.viewer_auto_resize_check.isChecked() self.viewer_auto_resize_check.isChecked()
APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText() APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText()
@@ -1108,3 +1396,111 @@ class SettingsDialog(QDialog):
def _on_downloader_finished(self): def _on_downloader_finished(self):
self.downloader_thread = None self.downloader_thread = None
def _stop_downloader_thread(self):
if self.downloader_thread and self.downloader_thread.isRunning():
self.downloader_thread.stop()
self.downloader_thread.wait()
self.downloader_thread = None
def done(self, r):
self._stop_downloader_thread() # Ensure downloader thread stops and waits.
if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop()
self.counter_thread.wait()
super().done(r)
def closeEvent(self, event):
self._stop_downloader_thread() # Ensure downloader thread stops and waits.
if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop()
self.counter_thread.wait()
super().closeEvent(event)
def _add_path_to_list(self, list_widget, path):
"""Adds a path to a QListWidget with existence validation."""
path = os.path.abspath(os.path.expanduser(path.strip()))
if not path:
return
to_remove = []
for i in range(list_widget.count()):
existing_p = list_widget.item(i).text()
if existing_p == path:
return
# If a parent folder already exists, do not add this subfolder.
if path.startswith(existing_p + os.sep):
return
# If the new path is a parent of an existing one, mark it for removal.
if existing_p.startswith(path + os.sep):
to_remove.append(i)
# Remove unnecessary subfolders (reverse order to not alter indices).
for i in sorted(to_remove, reverse=True):
list_widget.takeItem(i)
item = QListWidgetItem(path)
if not os.path.isdir(path):
item.setForeground(QColor("red"))
item.setToolTip(
UITexts.SETTINGS_PATH_NOT_FOUND_WARNING.format(path))
list_widget.addItem(item)
def add_whitelist_path(self):
"""Opens a directory dialog to add a folder to the whitelist."""
dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT)
if dir_path:
self._add_path_to_list(self.duplicate_whitelist_list, dir_path)
def remove_whitelist_path(self):
"""Removes the selected folders from the whitelist list."""
for item in self.duplicate_whitelist_list.selectedItems():
self.duplicate_whitelist_list.takeItem(
self.duplicate_whitelist_list.row(item))
def add_blacklist_path(self):
"""Opens a directory dialog to add a folder to the blacklist."""
dir_path = QFileDialog.getExistingDirectory(self, UITexts.SELECT)
if dir_path:
self._add_path_to_list(self.duplicate_blacklist_list, dir_path)
def remove_blacklist_path(self):
"""Removes the selected folders from the blacklist list."""
for item in self.duplicate_blacklist_list.selectedItems():
self.duplicate_blacklist_list.takeItem(
self.duplicate_blacklist_list.row(item))
def update_duplicate_scan_count(self):
"""Calculates and updates the count of images in whitelist/blacklist
using a background thread."""
if self.counter_thread and self.counter_thread.isRunning():
self.counter_thread.stop()
self.counter_thread.wait()
whitelist_paths = [self.duplicate_whitelist_list.item(i).text()
for i in range(self.duplicate_whitelist_list.count())]
blacklist_paths = [self.duplicate_blacklist_list.item(i).text()
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()]
blacklist = {os.path.abspath(os.path.expanduser(p.strip()))
for p in blacklist_paths if p.strip()}
if not whitelist:
self.duplicate_scan_count_label.setText(
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0))
self.duplicate_scan_progress.hide()
return
self.duplicate_scan_progress.show()
self.counter_thread = DuplicateFileCounter(
whitelist, blacklist, IMAGE_EXTENSIONS)
self.counter_thread.count_updated.connect(
lambda c: self.duplicate_scan_count_label.setText(
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c)))
self.counter_thread.finished.connect(
lambda: self.duplicate_scan_progress.hide())
self.counter_thread.start()

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="bagheeraview", name="bagheeraview",
version="0.9.14", 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 "
@@ -14,6 +14,9 @@ setup(
"PySide6", "PySide6",
"lmdb", "lmdb",
"exiv2", "exiv2",
"psutil",
"watchdog",
"imagehash", # Added for perceptual hashing
"mediapipe", "mediapipe",
"face_recognition", "face_recognition",
"face_recognition_models", "face_recognition_models",
@@ -33,10 +36,14 @@ setup(
"imagescanner", "imagescanner",
"imageviewer", "imageviewer",
"imagecontroller", "imagecontroller",
"filesystemwatcher",
"metadatamanager", "metadatamanager",
"propertiesdialog", "propertiesdialog",
"thumbnailwidget", #"thumbnailwidget",
"duplicatecache",
"duplicatedialog",
"widgets", "widgets",
"filesystemwatcher",
"xmpmanager", "xmpmanager",
"utils" "utils"
], ],

View File

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

View File

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