v0.9.19
This commit is contained in:
@@ -14,7 +14,7 @@ Classes:
|
|||||||
MainWindow: The main application window containing the thumbnail grid and docks.
|
MainWindow: The main application window containing the thumbnail grid and docks.
|
||||||
"""
|
"""
|
||||||
__appname__ = "BagheeraView"
|
__appname__ = "BagheeraView"
|
||||||
__version__ = "0.9.18"
|
__version__ = "0.9.19"
|
||||||
__author__ = "Ignacio Serantes"
|
__author__ = "Ignacio Serantes"
|
||||||
__email__ = "kde@aynoa.net"
|
__email__ = "kde@aynoa.net"
|
||||||
__license__ = "LGPL"
|
__license__ = "LGPL"
|
||||||
@@ -1839,7 +1839,7 @@ class MainWindow(QMainWindow):
|
|||||||
if paths is None:
|
if paths is None:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self, UITexts.WARNING,
|
self, UITexts.WARNING,
|
||||||
"Whitelist is empty. Please configure it in Settings.")
|
UITexts.DUPLICATE_WHITELIST_EMPTY)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not paths:
|
if not paths:
|
||||||
@@ -4666,7 +4666,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
action_other = menu.addAction(QIcon.fromTheme("applications-other"),
|
action_other = menu.addAction(QIcon.fromTheme("applications-other"),
|
||||||
"Open with other application...")
|
UITexts.OPEN_WITH_OTHER)
|
||||||
action_other.triggered.connect(
|
action_other.triggered.connect(
|
||||||
lambda: self.open_with_system_chooser(full_path))
|
lambda: self.open_with_system_chooser(full_path))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
129
constants.py
129
constants.py
@@ -29,7 +29,7 @@ if FORCE_X11:
|
|||||||
# --- CONFIGURATION ---
|
# --- CONFIGURATION ---
|
||||||
PROG_NAME = "Bagheera Image Viewer"
|
PROG_NAME = "Bagheera Image Viewer"
|
||||||
PROG_ID = "bagheeraview"
|
PROG_ID = "bagheeraview"
|
||||||
PROG_VERSION = "0.9.18"
|
PROG_VERSION = "0.9.19-dev"
|
||||||
PROG_AUTHOR = "Ignacio Serantes"
|
PROG_AUTHOR = "Ignacio Serantes"
|
||||||
|
|
||||||
# --- CACHE SETTINGS ---
|
# --- CACHE SETTINGS ---
|
||||||
@@ -393,6 +393,7 @@ _UI_TEXTS = {
|
|||||||
"SEARCH": "Search",
|
"SEARCH": "Search",
|
||||||
"SELECT": "Select",
|
"SELECT": "Select",
|
||||||
"ERROR": "Error",
|
"ERROR": "Error",
|
||||||
|
"FILE_NOT_FOUND": "File not found",
|
||||||
"WARNING": "Warning",
|
"WARNING": "Warning",
|
||||||
"INFO": "Info",
|
"INFO": "Info",
|
||||||
"LOAD": "Load",
|
"LOAD": "Load",
|
||||||
@@ -518,25 +519,39 @@ _UI_TEXTS = {
|
|||||||
"MENU_CLEAN_UP_HASHES": "Clean up",
|
"MENU_CLEAN_UP_HASHES": "Clean up",
|
||||||
"MENU_CLEAR_HASHES": "Clear hashes ({} items, {:.1f} MB on disk)",
|
"MENU_CLEAR_HASHES": "Clear hashes ({} items, {:.1f} MB on disk)",
|
||||||
"CONFIRM_CLEAR_HASHES_TITLE": "Confirm Clear Hashes",
|
"CONFIRM_CLEAR_HASHES_TITLE": "Confirm Clear Hashes",
|
||||||
"CONFIRM_CLEAR_HASHES_TEXT": "Are you sure you want to permanently delete the entire hash database?",
|
"CONFIRM_CLEAR_HASHES_TEXT": "Are you sure you want to permanently delete "
|
||||||
"CONFIRM_CLEAR_HASHES_INFO": "This will remove all calculated image hashes. They will be recalculated as you detect duplicates, which may be slow. This action cannot be undone.",
|
"the entire hash database?",
|
||||||
|
"CONFIRM_CLEAR_HASHES_INFO": "This will remove all calculated image hashes. "
|
||||||
|
"They will be recalculated as you detect duplicates, which may be slow. This "
|
||||||
|
"action cannot be undone.",
|
||||||
"SETTINGS_DUPLICATE_METHOD_LABEL": "Method:",
|
"SETTINGS_DUPLICATE_METHOD_LABEL": "Method:",
|
||||||
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Select the method for duplicate detection.",
|
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Select the method for duplicate "
|
||||||
|
"detection.",
|
||||||
"METHOD_HISTOGRAM_HASHING": "Histogram + Hashing",
|
"METHOD_HISTOGRAM_HASHING": "Histogram + Hashing",
|
||||||
"METHOD_RESNET": "ResNet (AI Based)",
|
"METHOD_RESNET": "ResNet (AI Based)",
|
||||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates",
|
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirm before deleting duplicates",
|
||||||
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Whitelist (folders to include):",
|
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Whitelist (folders to include):",
|
||||||
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Comma-separated paths of folders to scan when using 'Detect all'.",
|
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Comma-separated paths of folders to "
|
||||||
|
"scan when using 'Detect all'.",
|
||||||
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Blacklist (folders to exclude):",
|
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Blacklist (folders to exclude):",
|
||||||
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Comma-separated paths of folders to ignore during 'Detect all' scans.",
|
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Comma-separated paths of folders to "
|
||||||
|
"ignore during 'Detect all' scans.",
|
||||||
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Images found for 'Detect all': {}",
|
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Images found for 'Detect all': {}",
|
||||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by default",
|
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "Delete key sends to trash by "
|
||||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "If checked, pressing the Delete key will move files to trash. If unchecked, it will permanently delete them.",
|
"default",
|
||||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Show a confirmation dialog before moving a duplicate image to the trash.",
|
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "If checked, pressing the Delete "
|
||||||
|
"key will move files to trash. If unchecked, it will permanently delete them.",
|
||||||
|
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Show a confirmation dialog "
|
||||||
|
"before moving a duplicate image to the trash.",
|
||||||
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Similarity Threshold:",
|
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Similarity Threshold:",
|
||||||
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Set the similarity threshold (50-100%). Higher values mean images must be more similar to be considered duplicates.",
|
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Set the similarity threshold 2 "
|
||||||
"SETTINGS_DUPLICATE_MISSING_LIBS": "The 'imagehash' library is required for duplicate detection but was not found. This feature is disabled.",
|
"(50-100%). Higher values mean images must be more similar to be considered "
|
||||||
|
"duplicates.",
|
||||||
|
"SETTINGS_DUPLICATE_MISSING_LIBS": "The 'imagehash' library is required for "
|
||||||
|
"duplicate detection but was not found. This feature is disabled.",
|
||||||
"MENU_DETECT_DUPLICATES": "Detect Duplicates",
|
"MENU_DETECT_DUPLICATES": "Detect Duplicates",
|
||||||
|
"DUPLICATE_WHITELIST_EMPTY": "Whitelist is empty. Please configure it "
|
||||||
|
"in Settings.",
|
||||||
"DUPLICATE_DETECTION_TITLE": "Duplicate Detection",
|
"DUPLICATE_DETECTION_TITLE": "Duplicate Detection",
|
||||||
"DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.",
|
"DUPLICATE_ALREADY_RUNNING": "Duplicate detection is already in progress.",
|
||||||
"DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.",
|
"DUPLICATE_NO_IMAGES": "No images loaded to detect duplicates.",
|
||||||
@@ -617,6 +632,8 @@ _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_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",
|
||||||
@@ -913,6 +930,7 @@ _UI_TEXTS = {
|
|||||||
"SEARCH": "Buscar",
|
"SEARCH": "Buscar",
|
||||||
"SELECT": "Seleccionar",
|
"SELECT": "Seleccionar",
|
||||||
"ERROR": "Error",
|
"ERROR": "Error",
|
||||||
|
"FILE_NOT_FOUND": "Archivo no encontrado",
|
||||||
"WARNING": "Advertencia",
|
"WARNING": "Advertencia",
|
||||||
"INFO": "Información",
|
"INFO": "Información",
|
||||||
"LOAD": "Cargar",
|
"LOAD": "Cargar",
|
||||||
@@ -1038,25 +1056,43 @@ _UI_TEXTS = {
|
|||||||
"MENU_CLEAN_UP_HASHES": "Limpiar",
|
"MENU_CLEAN_UP_HASHES": "Limpiar",
|
||||||
"MENU_CLEAR_HASHES": "Limpiar hashes ({} ítems, {:.1f} MB en disco)",
|
"MENU_CLEAR_HASHES": "Limpiar hashes ({} ítems, {:.1f} MB en disco)",
|
||||||
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpieza de Hashes",
|
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpieza de Hashes",
|
||||||
"CONFIRM_CLEAR_HASHES_TEXT": "¿Seguro que quieres eliminar permanentemente toda la base de datos de hashes?",
|
"CONFIRM_CLEAR_HASHES_TEXT": "¿Seguro que quieres eliminar permanentemente "
|
||||||
"CONFIRM_CLEAR_HASHES_INFO": "Esto eliminará todos los hashes de imágenes calculados. Se recalcularán a medida que detectes duplicados, lo que puede ser lento. Esta acción no se puede deshacer.",
|
"toda la base de datos de hashes?",
|
||||||
|
"CONFIRM_CLEAR_HASHES_INFO": "Esto eliminará todos los hashes de imágenes "
|
||||||
|
"calculados. Se recalcularán a medida que detectes duplicados, lo que puede "
|
||||||
|
"ser lento. Esta acción no se puede deshacer.",
|
||||||
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
|
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
|
||||||
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona el método para la detección de duplicados.",
|
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona el método para la detección "
|
||||||
|
"de duplicados.",
|
||||||
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
|
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
|
||||||
"METHOD_RESNET": "ResNet (Basado en IA)",
|
"METHOD_RESNET": "ResNet (Basado en IA)",
|
||||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar duplicados",
|
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
|
||||||
|
"duplicados",
|
||||||
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista blanca (carpetas a incluir):",
|
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista blanca (carpetas a incluir):",
|
||||||
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de carpetas separadas por comas para escanear al usar 'Detectar todos'.",
|
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de carpetas separadas por comas "
|
||||||
|
"para escanear al usar 'Detectar todos'.",
|
||||||
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (carpetas a excluir):",
|
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (carpetas a excluir):",
|
||||||
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de carpetas separadas por comas para ignorar durante escaneos de 'Detectar todos'.",
|
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de carpetas separadas por comas "
|
||||||
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar todos': {}",
|
"para ignorar durante escaneos de 'Detectar todos'.",
|
||||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "La tecla Supr envía a la papelera por defecto",
|
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imágenes encontradas para 'Detectar "
|
||||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Si está marcada, al pulsar la tecla Supr se moverán los archivos a la papelera. Si no, se eliminarán permanentemente.",
|
"todos': {}",
|
||||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Muestra un diálogo de confirmación antes de mover una imagen duplicada a la papelera.",
|
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "La tecla Supr envía a la papelera "
|
||||||
|
"por defecto",
|
||||||
|
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Si está marcada, al pulsar la "
|
||||||
|
"tecla Supr se moverán los archivos a la papelera. Si no, se eliminarán "
|
||||||
|
"permanentemente.",
|
||||||
|
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Muestra un diálogo de "
|
||||||
|
"confirmación antes de mover una imagen duplicada a la papelera.",
|
||||||
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitud:",
|
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitud:",
|
||||||
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece el umbral de similitud (50-100%). Valores más altos significan que las imágenes deben ser más parecidas para considerarse duplicadas.",
|
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece el umbral de similitud "
|
||||||
"SETTINGS_DUPLICATE_MISSING_LIBS": "La librería 'imagehash' es necesaria para la detección de duplicados pero no se ha encontrado. Esta función está desactivada.",
|
"(50-100%). Valores más altos significan que las imágenes deben ser más "
|
||||||
|
"parecidas para considerarse duplicadas.",
|
||||||
|
"SETTINGS_DUPLICATE_MISSING_LIBS": "La librería 'imagehash' es necesaria "
|
||||||
|
"para la detección de duplicados pero no se ha encontrado. Esta función "
|
||||||
|
"está desactivada.",
|
||||||
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
|
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
|
||||||
|
"DUPLICATE_WHITELIST_EMPTY": "La lista blanca está vacía. Por favor, "
|
||||||
|
"configúrela en Opciones.",
|
||||||
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
|
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
|
||||||
"DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.",
|
"DUPLICATE_ALREADY_RUNNING": "La detección de duplicados ya está en curso.",
|
||||||
"DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.",
|
"DUPLICATE_NO_IMAGES": "No hay imágenes cargadas para detectar duplicados.",
|
||||||
@@ -1143,6 +1179,8 @@ _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_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:",
|
||||||
@@ -1441,6 +1479,7 @@ _UI_TEXTS = {
|
|||||||
"SEARCH": "Buscar",
|
"SEARCH": "Buscar",
|
||||||
"SELECT": "Seleccionar",
|
"SELECT": "Seleccionar",
|
||||||
"ERROR": "Erro",
|
"ERROR": "Erro",
|
||||||
|
"FILE_NOT_FOUND": "Ficheiro non atopado",
|
||||||
"WARNING": "Advertencia",
|
"WARNING": "Advertencia",
|
||||||
"INFO": "Información",
|
"INFO": "Información",
|
||||||
"LOAD": "Cargar",
|
"LOAD": "Cargar",
|
||||||
@@ -1567,25 +1606,42 @@ _UI_TEXTS = {
|
|||||||
"MENU_CLEAN_UP_HASHES": "Limpar",
|
"MENU_CLEAN_UP_HASHES": "Limpar",
|
||||||
"MENU_CLEAR_HASHES": "Limpar hashes ({} elementos, {:.1f} MB en disco)",
|
"MENU_CLEAR_HASHES": "Limpar hashes ({} elementos, {:.1f} MB en disco)",
|
||||||
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpeza de Hashes",
|
"CONFIRM_CLEAR_HASHES_TITLE": "Confirmar Limpeza de Hashes",
|
||||||
"CONFIRM_CLEAR_HASHES_TEXT": "Seguro que queres eliminar permanentemente toda a base de datos de hashes?",
|
"CONFIRM_CLEAR_HASHES_TEXT": "Seguro que queres eliminar permanentemente toda "
|
||||||
"CONFIRM_CLEAR_HASHES_INFO": "Isto eliminará todos os hashes de imaxes calculados. Rexeneraranse a medida que detectes duplicados, o que pode ser lento. Esta acción non se pode deshacer.",
|
"a base de datos de hashes?",
|
||||||
|
"CONFIRM_CLEAR_HASHES_INFO": "Isto eliminará todos os hashes de imaxes "
|
||||||
|
"calculados. Rexeneraranse a medida que detectes duplicados, o que pode ser "
|
||||||
|
"lento. Esta acción non se pode deshacer.",
|
||||||
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
|
"SETTINGS_DUPLICATE_METHOD_LABEL": "Método:",
|
||||||
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona o método para a detección de duplicados.",
|
"SETTINGS_DUPLICATE_METHOD_TOOLTIP": "Selecciona o método para a detección "
|
||||||
|
"de duplicados.",
|
||||||
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
|
"METHOD_HISTOGRAM_HASHING": "Histograma + Hashing",
|
||||||
"METHOD_RESNET": "ResNet (Baseado en IA)",
|
"METHOD_RESNET": "ResNet (Baseado en IA)",
|
||||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar duplicados",
|
"SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL": "Confirmar antes de borrar "
|
||||||
|
"duplicados",
|
||||||
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista branca (cartafoles a incluír):",
|
"SETTINGS_DUPLICATE_WHITELIST_LABEL": "Lista branca (cartafoles a incluír):",
|
||||||
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de cartafoles separadas por comas para escanear ao usar 'Detectar todos'.",
|
"SETTINGS_DUPLICATE_WHITELIST_TOOLTIP": "Rutas de cartafoles separadas por "
|
||||||
|
"comas para escanear ao usar 'Detectar todos'.",
|
||||||
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (cartafoles a excluír):",
|
"SETTINGS_DUPLICATE_BLACKLIST_LABEL": "Lista negra (cartafoles a excluír):",
|
||||||
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de cartafoles separadas por comas para ignorar durante escaneos de 'Detectar todos'.",
|
"SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP": "Rutas de cartafoles separadas por "
|
||||||
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar todos': {}",
|
"comas para ignorar durante escaneos de 'Detectar todos'.",
|
||||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "A tecla Supr envía á papeleira por defecto",
|
"SETTINGS_DUPLICATE_SCAN_COUNT_LABEL": "Imaxes atopadas para 'Detectar "
|
||||||
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Se está marcada, ao premer a tecla Supr moveranse os ficheiros á papeleira. Se non, eliminaranse permanentemente.",
|
"todos': {}",
|
||||||
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Amosa un diálogo de confirmación antes de mover unha imaxe duplicada á papeleira.",
|
"SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL": "A tecla Supr envía á papeleira por "
|
||||||
|
"defecto",
|
||||||
|
"SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP": "Se está marcada, ao premer a "
|
||||||
|
"tecla Supr moveranse os ficheiros á papeleira. Se non, eliminaranse "
|
||||||
|
"permanentemente.",
|
||||||
|
"SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP": "Amosa un diálogo de confirmación "
|
||||||
|
"antes de mover unha imaxe duplicada á papeleira.",
|
||||||
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitude:",
|
"SETTINGS_DUPLICATE_THRESHOLD_LABEL": "Umbral de Similitude:",
|
||||||
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece o umbral de similitude (50-100%). Valores máis altos significan que as imaxes deben ser máis parecidas para considerarse duplicadas.",
|
"SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP": "Establece o umbral de similitude "
|
||||||
"SETTINGS_DUPLICATE_MISSING_LIBS": "A librería 'imagehash' é necesaria para a detección de duplicados pero non se atopou. Esta función está desactivada.",
|
"(50-100%). Valores máis altos significan que as imaxes deben ser máis "
|
||||||
|
"parecidas para considerarse duplicadas.",
|
||||||
|
"SETTINGS_DUPLICATE_MISSING_LIBS": "A librería 'imagehash' é necesaria para a "
|
||||||
|
"detección de duplicados pero non se atopou. Esta función está desactivada.",
|
||||||
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
|
"MENU_DETECT_DUPLICATES": "Detectar Duplicados",
|
||||||
|
"DUPLICATE_WHITELIST_EMPTY": "A lista branca está baleira. Por favor, "
|
||||||
|
"configúrea en Opcións.",
|
||||||
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
|
"DUPLICATE_DETECTION_TITLE": "Detección de Duplicados",
|
||||||
"DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.",
|
"DUPLICATE_ALREADY_RUNNING": "A detección de duplicados xa está en curso.",
|
||||||
"DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.",
|
"DUPLICATE_NO_IMAGES": "Non hai imaxes cargadas para detectar duplicados.",
|
||||||
@@ -1672,6 +1728,8 @@ _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_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:",
|
||||||
@@ -1970,6 +2028,7 @@ _UI_TEXTS = {
|
|||||||
|
|
||||||
# Determine which language to use for UI strings
|
# Determine which language to use for UI strings
|
||||||
def _get_current_language():
|
def _get_current_language():
|
||||||
|
"""Determines the language to use for UI strings based on environment."""
|
||||||
lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE)
|
lang = os.getenv("BAGHEERA_LANG") or APP_CONFIG.get("language", DEFAULT_LANGUAGE)
|
||||||
|
|
||||||
if lang == "system":
|
if lang == "system":
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Duplicate Cache and Detection Module for Bagheera.
|
||||||
|
|
||||||
|
This module provides the core logic for detecting duplicate images using
|
||||||
|
perceptual hashing (dHash) and managing a persistent cache of these hashes
|
||||||
|
and their relationships using LMDB.
|
||||||
|
|
||||||
|
Classes:
|
||||||
|
DuplicateCache: Manages the LMDB database for hashes and exceptions.
|
||||||
|
DuplicateDetector: Background thread that performs the duplicate analysis.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
@@ -819,7 +830,8 @@ class DuplicateDetector(QThread):
|
|||||||
if time.perf_counter() - last_update_time > 0.05 \
|
if time.perf_counter() - last_update_time > 0.05 \
|
||||||
or i == 0 or i == total_queries - 1:
|
or i == 0 or i == total_queries - 1:
|
||||||
# Scale Comparison to 75% - 100% range
|
# Scale Comparison to 75% - 100% range
|
||||||
comparison_progress = int(((i + 1) / total_queries) * (total_files / 2)) \
|
comparison_progress = int(((i + 1) / total_queries)
|
||||||
|
* (total_files / 2)) \
|
||||||
if total_queries > 0 else (total_files / 2)
|
if total_queries > 0 else (total_files / 2)
|
||||||
self.progress_update.emit(
|
self.progress_update.emit(
|
||||||
int(total_files * 1.5 + comparison_progress), total_files * 2,
|
int(total_files * 1.5 + comparison_progress), total_files * 2,
|
||||||
@@ -856,10 +868,13 @@ class DuplicateDetector(QThread):
|
|||||||
|
|
||||||
# Frequent UI heartbeat for large duplicate groups
|
# Frequent UI heartbeat for large duplicate groups
|
||||||
if time.perf_counter() - last_update_time > 0.05:
|
if time.perf_counter() - last_update_time > 0.05:
|
||||||
comparison_progress = int(((i + 1) / total_queries) * (total_files / 2))
|
comparison_progress = int(((i + 1) / total_queries)
|
||||||
|
* (total_files / 2))
|
||||||
self.progress_update.emit(
|
self.progress_update.emit(
|
||||||
int(total_files * 1.5 + comparison_progress), total_files * 2,
|
int(total_files * 1.5 + comparison_progress),
|
||||||
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
|
total_files * 2,
|
||||||
|
UITexts.DUPLICATE_MSG_ANALYZING.format(
|
||||||
|
filename="..."))
|
||||||
last_update_time = time.perf_counter()
|
last_update_time = time.perf_counter()
|
||||||
|
|
||||||
# Collect for batch update to improve performance
|
# Collect for batch update to improve performance
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.table_widget.setCurrentCell(0, 0)
|
self.table_widget.setCurrentCell(0, 0)
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
|
"""Sets up the user interface components for the duplicate manager."""
|
||||||
layout = QHBoxLayout(self)
|
layout = QHBoxLayout(self)
|
||||||
|
|
||||||
# Left side: List of pairs
|
# Left side: List of pairs
|
||||||
@@ -181,15 +182,79 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
"""Resizes the images to fill available space when the dialog is resized."""
|
"""Resizes the images to fill available space when the dialog is resized."""
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event) # Call base class resizeEvent
|
||||||
if hasattr(self, 'left_pane') and self.left_pane and \
|
self._apply_linked_scaling()
|
||||||
hasattr(self, 'right_pane') and self.right_pane:
|
|
||||||
self._is_syncing = True
|
def _apply_linked_scaling(self):
|
||||||
|
"""Applies custom linked scaling logic to both panels."""
|
||||||
|
if not self.left_pane or not self.right_pane:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure images are loaded to get original dimensions.
|
||||||
|
# This also ensures pane.controller.pixmap_original is populated.
|
||||||
|
self.left_pane.controller.load_image()
|
||||||
|
self.right_pane.controller.load_image()
|
||||||
|
|
||||||
|
p_l = self.left_pane.controller.pixmap_original
|
||||||
|
p_r = self.right_pane.controller.pixmap_original
|
||||||
|
|
||||||
|
# If panels are not linked or any image is null, adjust independently
|
||||||
|
if not self.panes_linked or p_l.isNull() or p_r.isNull():
|
||||||
|
self._is_syncing = True # Avoid recursion in _sync_zoom
|
||||||
try:
|
try:
|
||||||
self.load_and_fit_image_for_pane(self.left_pane)
|
self.load_and_fit_image_for_pane(self.left_pane)
|
||||||
self.load_and_fit_image_for_pane(self.right_pane)
|
self.load_and_fit_image_for_pane(self.right_pane)
|
||||||
finally:
|
finally:
|
||||||
self._is_syncing = False
|
self._is_syncing = False
|
||||||
|
return
|
||||||
|
|
||||||
|
self._is_syncing = True
|
||||||
|
try:
|
||||||
|
# Get original dimensions
|
||||||
|
w_l_orig, h_l_orig = p_l.width(), p_l.height()
|
||||||
|
w_r_orig, h_r_orig = p_r.width(), p_r.height()
|
||||||
|
|
||||||
|
# Get available viewport size for each panel
|
||||||
|
viewport_l = self.left_pane.scroll_area.viewport()
|
||||||
|
viewport_r = self.right_pane.scroll_area.viewport()
|
||||||
|
vp_w_l, vp_h_l = viewport_l.width(), viewport_l.height()
|
||||||
|
vp_w_r, vp_h_r = viewport_r.width(), viewport_r.height()
|
||||||
|
|
||||||
|
# Determine the highest resolution image
|
||||||
|
res_l = w_l_orig * h_l_orig
|
||||||
|
res_r = w_r_orig * h_r_orig
|
||||||
|
|
||||||
|
if res_l >= res_r:
|
||||||
|
high_res_pane = self.left_pane
|
||||||
|
low_res_pane = self.right_pane
|
||||||
|
high_res_w, high_res_h = w_l_orig, h_l_orig
|
||||||
|
low_res_w, low_res_h = w_r_orig, h_r_orig
|
||||||
|
vp_w_high, vp_h_high = vp_w_l, vp_h_l
|
||||||
|
else:
|
||||||
|
high_res_pane = self.right_pane
|
||||||
|
low_res_pane = self.left_pane
|
||||||
|
high_res_w, high_res_h = w_r_orig, h_r_orig
|
||||||
|
low_res_w, low_res_h = w_l_orig, h_l_orig
|
||||||
|
vp_w_high, vp_h_high = vp_w_r, vp_h_r
|
||||||
|
|
||||||
|
# Calculate zoom factor for high-res image to fit its panel
|
||||||
|
zoom_high = 1.0
|
||||||
|
if high_res_w > 0 and high_res_h > 0:
|
||||||
|
zoom_high = min(vp_w_high / high_res_w, vp_h_high / high_res_h)
|
||||||
|
|
||||||
|
high_res_pane.controller.zoom_factor = zoom_high
|
||||||
|
high_res_pane.update_view(resize_win=False)
|
||||||
|
|
||||||
|
# Calculate and apply zoom for low-res image relative to high-res
|
||||||
|
zoom_low = 1.0
|
||||||
|
if high_res_w > 0 and high_res_h > 0:
|
||||||
|
relative_scale_factor = min(low_res_w / high_res_w,
|
||||||
|
low_res_h / high_res_h)
|
||||||
|
zoom_low = zoom_high * relative_scale_factor
|
||||||
|
low_res_pane.controller.zoom_factor = zoom_low
|
||||||
|
low_res_pane.update_view(resize_win=False)
|
||||||
|
finally:
|
||||||
|
self._is_syncing = False
|
||||||
|
|
||||||
def wheelEvent(self, event):
|
def wheelEvent(self, event):
|
||||||
"""Handles mouse wheel events for zooming (with Ctrl)."""
|
"""Handles mouse wheel events for zooming (with Ctrl)."""
|
||||||
@@ -340,6 +405,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
return widget
|
return widget
|
||||||
|
|
||||||
def _populate_list(self):
|
def _populate_list(self):
|
||||||
|
"""Fills the table widget with the list of duplicate results."""
|
||||||
self.table_widget.setSortingEnabled(False)
|
self.table_widget.setSortingEnabled(False)
|
||||||
self.table_widget.blockSignals(True)
|
self.table_widget.blockSignals(True)
|
||||||
self.table_widget.setRowCount(0)
|
self.table_widget.setRowCount(0)
|
||||||
@@ -508,21 +574,29 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.right_pane_widget.info_lbl.setStyleSheet(
|
self.right_pane_widget.info_lbl.setStyleSheet(
|
||||||
"font-weight: bold; color: #aaa;")
|
"font-weight: bold; color: #aaa;")
|
||||||
|
|
||||||
|
# Force view update and proportional scaling
|
||||||
|
self._apply_linked_scaling()
|
||||||
|
|
||||||
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
|
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
|
||||||
filename_text, dir_text) -> bool:
|
filename_text, dir_text) -> bool:
|
||||||
|
"""Updates an ImagePane and its labels with file data."""
|
||||||
pane = pane_widget.pane
|
pane = pane_widget.pane
|
||||||
info_lbl = pane_widget.info_lbl
|
info_lbl = pane_widget.info_lbl
|
||||||
filename_lbl = pane_widget.filename_lbl
|
filename_lbl = pane_widget.filename_lbl
|
||||||
dir_lbl = pane_widget.dir_lbl
|
dir_lbl = pane_widget.dir_lbl
|
||||||
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
info_lbl.setText("FILE NOT FOUND")
|
info_lbl.setText(UITexts.FILE_NOT_FOUND)
|
||||||
pane.controller.update_list([], 0) # Clear pane
|
pane.controller.update_list([], 0) # Clear pane
|
||||||
pane.load_and_fit_image()
|
pane.controller.load_image()
|
||||||
filename_lbl.setText("N/A")
|
filename_lbl.setText("N/A")
|
||||||
dir_lbl.setText("N/A")
|
dir_lbl.setText("N/A")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Load image into pane's controller FIRST to get accurate pixmap state
|
||||||
|
pane.controller.update_list([path], 0)
|
||||||
|
pane.controller.load_image()
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
size_bytes = os.path.getsize(path)
|
size_bytes = os.path.getsize(path)
|
||||||
size_str = self._format_size(size_bytes)
|
size_str = self._format_size(size_bytes)
|
||||||
@@ -534,14 +608,6 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
not pane.controller.pixmap_original.size().isValid())
|
not pane.controller.pixmap_original.size().isValid())
|
||||||
disable_linking = is_animated or is_invalid
|
disable_linking = is_animated or is_invalid
|
||||||
|
|
||||||
self.panes_linked = self._user_link_preference and disable_linking
|
|
||||||
self.btn_link_panes.setEnabled(disable_linking)
|
|
||||||
self.btn_link_panes.setChecked(self.panes_linked)
|
|
||||||
|
|
||||||
# Load image into pane's controller
|
|
||||||
pane.controller.update_list([path], 0)
|
|
||||||
pane.load_and_fit_image()
|
|
||||||
|
|
||||||
# Update info labels
|
# Update info labels
|
||||||
if not pane.controller.pixmap_original.isNull():
|
if not pane.controller.pixmap_original.isNull():
|
||||||
info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format(
|
info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format(
|
||||||
@@ -559,6 +625,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
return disable_linking
|
return disable_linking
|
||||||
|
|
||||||
def _show_pane_context_menu(self, pos):
|
def _show_pane_context_menu(self, pos):
|
||||||
|
"""Displays a context menu for the pane that requested it."""
|
||||||
pane = self.sender()
|
pane = self.sender()
|
||||||
path = pane.controller.get_current_path()
|
path = pane.controller.get_current_path()
|
||||||
if not path or not os.path.exists(path):
|
if not path or not os.path.exists(path):
|
||||||
@@ -618,6 +685,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
menu.exec(pane.mapToGlobal(pos))
|
menu.exec(pane.mapToGlobal(pos))
|
||||||
|
|
||||||
def _handle_permanent_delete(self, path):
|
def _handle_permanent_delete(self, path):
|
||||||
|
"""Prompts for and executes permanent deletion of a file."""
|
||||||
confirm = QMessageBox(self)
|
confirm = QMessageBox(self)
|
||||||
confirm.setIcon(QMessageBox.Warning)
|
confirm.setIcon(QMessageBox.Warning)
|
||||||
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
|
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
|
||||||
@@ -629,6 +697,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._handle_action(delete_path=path, permanent=True)
|
self._handle_action(delete_path=path, permanent=True)
|
||||||
|
|
||||||
def _show_properties(self, path, pane):
|
def _show_properties(self, path, pane):
|
||||||
|
"""Shows the file properties dialog for a pane's image."""
|
||||||
tags = pane.controller._current_tags
|
tags = pane.controller._current_tags
|
||||||
rating = pane.controller._current_rating
|
rating = pane.controller._current_rating
|
||||||
dlg = PropertiesDialog(
|
dlg = PropertiesDialog(
|
||||||
@@ -636,6 +705,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
def _on_pane_activated(self):
|
def _on_pane_activated(self):
|
||||||
|
"""Handles pane activation to synchronize viewing state if linked."""
|
||||||
# When a pane is activated, ensure its zoom/scroll is the reference for linking
|
# When a pane is activated, ensure its zoom/scroll is the reference for linking
|
||||||
if self.panes_linked:
|
if self.panes_linked:
|
||||||
active_pane = self.sender() # The pane that emitted activated signal
|
active_pane = self.sender() # The pane that emitted activated signal
|
||||||
@@ -650,6 +720,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
other_pane.set_scroll_relative(x_pct, y_pct)
|
other_pane.set_scroll_relative(x_pct, y_pct)
|
||||||
|
|
||||||
def _sync_scroll(self, x_pct, y_pct):
|
def _sync_scroll(self, x_pct, y_pct):
|
||||||
|
"""Synchronizes scroll position between panes if linked."""
|
||||||
if not self.panes_linked:
|
if not self.panes_linked:
|
||||||
return
|
return
|
||||||
source_pane = self.sender()
|
source_pane = self.sender()
|
||||||
@@ -659,35 +730,65 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.left_pane.set_scroll_relative(x_pct, y_pct)
|
self.left_pane.set_scroll_relative(x_pct, y_pct)
|
||||||
|
|
||||||
def _sync_zoom(self, factor, source_pane=None):
|
def _sync_zoom(self, factor, source_pane=None):
|
||||||
|
"""Synchronizes zoom factor between panes if linked."""
|
||||||
if not self.panes_linked or self._is_syncing:
|
if not self.panes_linked or self._is_syncing:
|
||||||
return
|
return
|
||||||
if source_pane is None:
|
if source_pane is None:
|
||||||
# El emisor es el ZoomManager, su padre es el ImagePane
|
# Emitter is ZoomManager, its parent is ImagePane
|
||||||
sender = self.sender()
|
sender = self.sender()
|
||||||
source_pane = sender.parent() if sender else None
|
source_pane = sender.parent() if sender else None
|
||||||
|
|
||||||
if not source_pane:
|
if not source_pane:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Ensure both images are loaded before syncing zoom
|
||||||
|
if self.left_pane.controller.pixmap_original.isNull() or \
|
||||||
|
self.right_pane.controller.pixmap_original.isNull():
|
||||||
|
return
|
||||||
|
|
||||||
self._is_syncing = True
|
self._is_syncing = True
|
||||||
try:
|
try:
|
||||||
# Capture current scroll percentage from source to apply to target
|
p_l = self.left_pane.controller.pixmap_original
|
||||||
h_bar = source_pane.scroll_area.horizontalScrollBar()
|
p_r = self.right_pane.controller.pixmap_original
|
||||||
v_bar = source_pane.scroll_area.verticalScrollBar()
|
|
||||||
x_pct = h_bar.value() / h_bar.maximum() if h_bar.maximum() > 0 else 0
|
|
||||||
y_pct = v_bar.value() / v_bar.maximum() if v_bar.maximum() > 0 else 0
|
|
||||||
|
|
||||||
target_pane = self.left_pane \
|
w_l_orig, h_l_orig = p_l.width(), p_l.height()
|
||||||
if source_pane == self.right_pane else self.right_pane
|
w_r_orig, h_r_orig = p_r.width(), p_r.height()
|
||||||
target_pane.zoom_manager.zoom(absolute_factor=factor)
|
|
||||||
|
|
||||||
# Re-apply relative scroll after zoom changes bounds
|
if w_l_orig == 0 or h_l_orig == 0 or w_r_orig == 0 or h_r_orig == 0:
|
||||||
QTimer.singleShot(
|
return # Avoid division by zero
|
||||||
0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y))
|
|
||||||
|
# Calculate original size relationship.
|
||||||
|
# Use ratio of "master" (high-res) to "slave" (low-res)
|
||||||
|
# to maintain relative size.
|
||||||
|
res_l = w_l_orig * h_l_orig
|
||||||
|
res_r = w_r_orig * h_r_orig
|
||||||
|
|
||||||
|
if res_l >= res_r: # Left is same or higher resolution
|
||||||
|
high_res_w, high_res_h = w_l_orig, h_l_orig
|
||||||
|
low_res_w, low_res_h = w_r_orig, h_r_orig
|
||||||
|
high_res_pane = self.left_pane
|
||||||
|
low_res_pane = self.right_pane
|
||||||
|
else: # Right is higher resolution
|
||||||
|
high_res_w, high_res_h = w_r_orig, h_r_orig
|
||||||
|
low_res_w, low_res_h = w_l_orig, h_l_orig
|
||||||
|
high_res_pane = self.right_pane
|
||||||
|
low_res_pane = self.left_pane
|
||||||
|
|
||||||
|
# 'factor' is the new zoom factor of the source panel.
|
||||||
|
# Apply this to the high-res panel, then calculate low-res zoom.
|
||||||
|
if source_pane == high_res_pane:
|
||||||
|
low_res_pane.controller.zoom_factor = factor * min(
|
||||||
|
low_res_w / high_res_w, low_res_h / high_res_h)
|
||||||
|
low_res_pane.update_view(resize_win=False)
|
||||||
|
else: # source_pane == low_res_pane
|
||||||
|
high_res_pane.controller.zoom_factor = factor / min(
|
||||||
|
low_res_w / high_res_w, low_res_h / high_res_h)
|
||||||
|
high_res_pane.update_view(resize_win=False)
|
||||||
finally:
|
finally:
|
||||||
self._is_syncing = False
|
self._is_syncing = False
|
||||||
|
|
||||||
def _format_size(self, size):
|
def _format_size(self, size):
|
||||||
|
"""Formats a file size in bytes to a human-readable string."""
|
||||||
for unit in ['B', 'KiB', 'MiB', 'GiB']:
|
for unit in ['B', 'KiB', 'MiB', 'GiB']:
|
||||||
if size < 1024:
|
if size < 1024:
|
||||||
return f"{size:.1f} {unit}"
|
return f"{size:.1f} {unit}"
|
||||||
@@ -695,16 +796,19 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
return f"{size:.1f} TiB"
|
return f"{size:.1f} TiB"
|
||||||
|
|
||||||
def _delete_left(self):
|
def _delete_left(self):
|
||||||
|
"""Triggers deletion of the image in the left pane."""
|
||||||
path_to_delete = self.left_pane.controller.get_current_path()
|
path_to_delete = self.left_pane.controller.get_current_path()
|
||||||
if path_to_delete:
|
if path_to_delete:
|
||||||
self._handle_action(delete_path=path_to_delete)
|
self._handle_action(delete_path=path_to_delete)
|
||||||
|
|
||||||
def _delete_right(self):
|
def _delete_right(self):
|
||||||
|
"""Triggers deletion of the image in the right pane."""
|
||||||
path_to_delete = self.right_pane.controller.get_current_path()
|
path_to_delete = self.right_pane.controller.get_current_path()
|
||||||
if path_to_delete:
|
if path_to_delete:
|
||||||
self._handle_action(delete_path=path_to_delete)
|
self._handle_action(delete_path=path_to_delete)
|
||||||
|
|
||||||
def _toggle_link_panes(self):
|
def _toggle_link_panes(self):
|
||||||
|
"""Toggles the link state between panes."""
|
||||||
self._user_link_preference = self.btn_link_panes.isChecked()
|
self._user_link_preference = self.btn_link_panes.isChecked()
|
||||||
self.panes_linked = self._user_link_preference
|
self.panes_linked = self._user_link_preference
|
||||||
if self.panes_linked:
|
if self.panes_linked:
|
||||||
@@ -767,6 +871,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.table_widget.setCurrentCell(new_row, 0)
|
self.table_widget.setCurrentCell(new_row, 0)
|
||||||
|
|
||||||
def _keep_both(self):
|
def _keep_both(self):
|
||||||
|
"""Marks the current pair as an exception to ignore in future scans."""
|
||||||
if self.current_dup_pair:
|
if self.current_dup_pair:
|
||||||
self.cache.mark_as_exception(
|
self.cache.mark_as_exception(
|
||||||
self.current_dup_pair.path1,
|
self.current_dup_pair.path1,
|
||||||
@@ -777,6 +882,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._handle_action(skip=False, permanent=False)
|
self._handle_action(skip=False, permanent=False)
|
||||||
|
|
||||||
def _skip(self):
|
def _skip(self):
|
||||||
|
"""Skips the current pair without marking it as an exception."""
|
||||||
if self.review_mode and self.current_dup_pair:
|
if self.review_mode and self.current_dup_pair:
|
||||||
self.cache.mark_as_exception(
|
self.cache.mark_as_exception(
|
||||||
self.current_dup_pair.path1, self.current_dup_pair.path2, False)
|
self.current_dup_pair.path1, self.current_dup_pair.path2, False)
|
||||||
@@ -793,6 +899,13 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._handle_action(skip=True)
|
self._handle_action(skip=True)
|
||||||
|
|
||||||
def _handle_action(self, delete_path=None, skip=False, permanent=None):
|
def _handle_action(self, delete_path=None, skip=False, permanent=None):
|
||||||
|
"""
|
||||||
|
Handles management actions (delete, skip, keep) for duplicate pairs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delete_path: Path to delete, if any.
|
||||||
|
skip: Whether to skip the current pair.
|
||||||
|
"""
|
||||||
current_row = self.table_widget.currentRow()
|
current_row = self.table_widget.currentRow()
|
||||||
if current_row < 0:
|
if current_row < 0:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class FileSystemWatcher(QObject):
|
|||||||
file_modified = Signal(str)
|
file_modified = Signal(str)
|
||||||
_file_modified_from_handler = Signal(str) # Internal signal from handler thread
|
_file_modified_from_handler = Signal(str) # Internal signal from handler thread
|
||||||
file_moved = Signal(str, str)
|
file_moved = Signal(str, str)
|
||||||
monitoring_status_changed = Signal(bool) # Nuevo: Señal para el estado de monitoreo
|
monitoring_status_changed = Signal(bool) # New: Signal for monitoring status
|
||||||
directory_moved = Signal(str, str)
|
directory_moved = Signal(str, str)
|
||||||
directory_modified = Signal(str) # For changes that might not be specific files
|
directory_modified = Signal(str) # For changes that might not be specific files
|
||||||
|
|
||||||
@@ -158,6 +158,7 @@ class FileSystemWatcher(QObject):
|
|||||||
self.watcher = watcher
|
self.watcher = watcher
|
||||||
|
|
||||||
def on_created(self, event):
|
def on_created(self, event):
|
||||||
|
"""Called when a file or directory is created."""
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
self.watcher.directory_modified.emit(event.src_path)
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
return
|
return
|
||||||
@@ -165,6 +166,7 @@ class FileSystemWatcher(QObject):
|
|||||||
self.watcher.file_created.emit(event.src_path)
|
self.watcher.file_created.emit(event.src_path)
|
||||||
|
|
||||||
def on_deleted(self, event):
|
def on_deleted(self, event):
|
||||||
|
"""Called when a file or directory is deleted."""
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
self.watcher.directory_modified.emit(event.src_path)
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
return
|
return
|
||||||
@@ -172,6 +174,7 @@ class FileSystemWatcher(QObject):
|
|||||||
self.watcher.file_deleted.emit(event.src_path)
|
self.watcher.file_deleted.emit(event.src_path)
|
||||||
|
|
||||||
def on_moved(self, event):
|
def on_moved(self, event):
|
||||||
|
"""Called when a file or directory is moved or renamed."""
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
self.watcher.directory_moved.emit(event.src_path, event.dest_path)
|
self.watcher.directory_moved.emit(event.src_path, event.dest_path)
|
||||||
self.watcher.directory_modified.emit(event.src_path)
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
@@ -180,6 +183,7 @@ class FileSystemWatcher(QObject):
|
|||||||
self.watcher.file_moved.emit(event.src_path, event.dest_path)
|
self.watcher.file_moved.emit(event.src_path, event.dest_path)
|
||||||
|
|
||||||
def on_closed(self, event):
|
def on_closed(self, event):
|
||||||
|
"""Called when a file is closed."""
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
self.watcher.directory_modified.emit(event.src_path)
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
return
|
return
|
||||||
@@ -187,6 +191,7 @@ class FileSystemWatcher(QObject):
|
|||||||
self.watcher.file_modified.emit(event.src_path)
|
self.watcher.file_modified.emit(event.src_path)
|
||||||
|
|
||||||
def on_modified(self, event):
|
def on_modified(self, event):
|
||||||
|
"""Called when a file or directory is modified."""
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
self.watcher.directory_modified.emit(event.src_path)
|
self.watcher.directory_modified.emit(event.src_path)
|
||||||
return
|
return
|
||||||
@@ -194,9 +199,11 @@ class FileSystemWatcher(QObject):
|
|||||||
self.watcher._file_modified_from_handler.emit(event.src_path)
|
self.watcher._file_modified_from_handler.emit(event.src_path)
|
||||||
|
|
||||||
def _emit_modified(self, path):
|
def _emit_modified(self, path):
|
||||||
|
"""Internal helper to emit the modified signal."""
|
||||||
self.watcher.file_modified.emit(path)
|
self.watcher.file_modified.emit(path)
|
||||||
if path in self.watcher._modified_events_queue:
|
if path in self.watcher._modified_events_queue:
|
||||||
del self.watcher._modified_events_queue[path]
|
del self.watcher._modified_events_queue[path]
|
||||||
|
|
||||||
def _is_image_file(self, path):
|
def _is_image_file(self, path):
|
||||||
|
"""Checks if a given path has a supported image extension."""
|
||||||
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS
|
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS
|
||||||
|
|||||||
@@ -745,9 +745,9 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1168,26 +1168,25 @@ class ZoomManager(QObject):
|
|||||||
v_point = viewport.mapFrom(self.viewer, focus_point)
|
v_point = viewport.mapFrom(self.viewer, focus_point)
|
||||||
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
|
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
|
||||||
else:
|
else:
|
||||||
# 1. Determinar el punto de enfoque en coordenadas del viewport
|
# 1. Determine focus point in viewport coordinates
|
||||||
scroll_area = self.viewer.scroll_area
|
scroll_area = self.viewer.scroll_area
|
||||||
viewport = scroll_area.viewport()
|
viewport = scroll_area.viewport()
|
||||||
|
|
||||||
if focus_point is None:
|
if focus_point is None:
|
||||||
v_point = viewport.rect().center()
|
v_point = viewport.rect().center()
|
||||||
else:
|
else:
|
||||||
# focus_point es relativo al widget self.viewer (ImageViewer o
|
# focus_point is relative to the self.viewer widget
|
||||||
# ImagePane)
|
# (ImageViewer or ImagePane)
|
||||||
v_point = viewport.mapFrom(self.viewer, focus_point)
|
v_point = viewport.mapFrom(self.viewer, focus_point)
|
||||||
|
|
||||||
# 2. Mapear el punto de enfoque a coordenadas del canvas antes del zoom
|
# 2. Map focus point to canvas coordinates before zoom
|
||||||
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
|
c_point = self.viewer.canvas.mapFrom(viewport, v_point)
|
||||||
|
|
||||||
self.viewer.controller.zoom_factor *= factor
|
self.viewer.controller.zoom_factor *= factor
|
||||||
# Aplicar la actualización (esto redimensiona el canvas)
|
# Apply update (this resizes the canvas)
|
||||||
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
|
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
|
||||||
|
|
||||||
# 3. Ajustar las barras de desplazamiento para mantener el píxel bajo el
|
# 3. Adjust scrollbars to maintain pixel under cursor
|
||||||
# cursor
|
|
||||||
scroll_area.horizontalScrollBar().setValue(
|
scroll_area.horizontalScrollBar().setValue(
|
||||||
int(c_point.x() * factor - v_point.x()))
|
int(c_point.x() * factor - v_point.x()))
|
||||||
scroll_area.verticalScrollBar().setValue(
|
scroll_area.verticalScrollBar().setValue(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bagheeraview"
|
name = "bagheeraview"
|
||||||
version = "0.9.18"
|
version = "0.9.19"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Ignacio Serantes" }
|
{ name = "Ignacio Serantes" }
|
||||||
]
|
]
|
||||||
|
|||||||
96
settings.py
96
settings.py
@@ -55,7 +55,7 @@ class DuplicateFileCounter(QThread):
|
|||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._abort = True
|
self._abort = True
|
||||||
self.wait() # Add this line
|
self.wait()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
count = 0
|
count = 0
|
||||||
@@ -68,7 +68,8 @@ class DuplicateFileCounter(QThread):
|
|||||||
if self._abort:
|
if self._abort:
|
||||||
break
|
break
|
||||||
abs_root = os.path.abspath(root)
|
abs_root = os.path.abspath(root)
|
||||||
dirs[:] = [d for d in dirs if os.path.join(abs_root, d) not in self.blacklist]
|
dirs[:] = [d for d in dirs
|
||||||
|
if os.path.join(abs_root, d) not in self.blacklist]
|
||||||
if abs_root in self.blacklist:
|
if abs_root in self.blacklist:
|
||||||
continue
|
continue
|
||||||
for f in files:
|
for f in files:
|
||||||
@@ -422,7 +423,8 @@ class SettingsDialog(QDialog):
|
|||||||
method_layout = QHBoxLayout()
|
method_layout = QHBoxLayout()
|
||||||
method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL)
|
method_label = QLabel(UITexts.SETTINGS_DUPLICATE_METHOD_LABEL)
|
||||||
self.duplicate_method_combo = QComboBox()
|
self.duplicate_method_combo = QComboBox()
|
||||||
self.duplicate_method_combo.addItem(UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing")
|
self.duplicate_method_combo.addItem(
|
||||||
|
UITexts.METHOD_HISTOGRAM_HASHING, "histogram_hashing")
|
||||||
self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet")
|
self.duplicate_method_combo.addItem(UITexts.METHOD_RESNET, "resnet")
|
||||||
|
|
||||||
self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH)
|
self.duplicate_method_combo.setEnabled(HAVE_IMAGEHASH)
|
||||||
@@ -437,7 +439,8 @@ class SettingsDialog(QDialog):
|
|||||||
method_layout.addWidget(method_label)
|
method_layout.addWidget(method_label)
|
||||||
method_layout.addWidget(self.duplicate_method_combo)
|
method_layout.addWidget(self.duplicate_method_combo)
|
||||||
method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
|
method_label.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
|
||||||
self.duplicate_method_combo.setToolTip(UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
|
self.duplicate_method_combo.setToolTip(
|
||||||
|
UITexts.SETTINGS_DUPLICATE_METHOD_TOOLTIP)
|
||||||
duplicates_layout.addLayout(method_layout)
|
duplicates_layout.addLayout(method_layout)
|
||||||
|
|
||||||
threshold_layout = QHBoxLayout()
|
threshold_layout = QHBoxLayout()
|
||||||
@@ -454,7 +457,8 @@ class SettingsDialog(QDialog):
|
|||||||
threshold_layout.addWidget(self.duplicate_threshold_value_label)
|
threshold_layout.addWidget(self.duplicate_threshold_value_label)
|
||||||
|
|
||||||
threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
|
threshold_label.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
|
||||||
self.duplicate_threshold_slider.setToolTip(UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
|
self.duplicate_threshold_slider.setToolTip(
|
||||||
|
UITexts.SETTINGS_DUPLICATE_THRESHOLD_TOOLTIP)
|
||||||
|
|
||||||
self.duplicate_threshold_slider.valueChanged.connect(
|
self.duplicate_threshold_slider.valueChanged.connect(
|
||||||
lambda v: self.duplicate_threshold_value_label.setText(f"{v}%"))
|
lambda v: self.duplicate_threshold_value_label.setText(f"{v}%"))
|
||||||
@@ -485,14 +489,16 @@ class SettingsDialog(QDialog):
|
|||||||
|
|
||||||
# Whitelist
|
# Whitelist
|
||||||
wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui(
|
wl_cont, self.duplicate_whitelist_list, wl_add, wl_rem = create_path_list_ui(
|
||||||
UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL, UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP)
|
UITexts.SETTINGS_DUPLICATE_WHITELIST_LABEL,
|
||||||
|
UITexts.SETTINGS_DUPLICATE_WHITELIST_TOOLTIP)
|
||||||
wl_add.clicked.connect(self.add_whitelist_path)
|
wl_add.clicked.connect(self.add_whitelist_path)
|
||||||
wl_rem.clicked.connect(self.remove_whitelist_path)
|
wl_rem.clicked.connect(self.remove_whitelist_path)
|
||||||
duplicates_layout.addWidget(wl_cont)
|
duplicates_layout.addWidget(wl_cont)
|
||||||
|
|
||||||
# Blacklist
|
# Blacklist
|
||||||
bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui(
|
bl_cont, self.duplicate_blacklist_list, bl_add, bl_rem = create_path_list_ui(
|
||||||
UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL, UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP)
|
UITexts.SETTINGS_DUPLICATE_BLACKLIST_LABEL,
|
||||||
|
UITexts.SETTINGS_DUPLICATE_BLACKLIST_TOOLTIP)
|
||||||
bl_add.clicked.connect(self.add_blacklist_path)
|
bl_add.clicked.connect(self.add_blacklist_path)
|
||||||
bl_rem.clicked.connect(self.remove_blacklist_path)
|
bl_rem.clicked.connect(self.remove_blacklist_path)
|
||||||
duplicates_layout.addWidget(bl_cont)
|
duplicates_layout.addWidget(bl_cont)
|
||||||
@@ -500,7 +506,8 @@ class SettingsDialog(QDialog):
|
|||||||
# Image Count Layout
|
# Image Count Layout
|
||||||
count_layout = QHBoxLayout()
|
count_layout = QHBoxLayout()
|
||||||
self.duplicate_scan_count_label = QLabel()
|
self.duplicate_scan_count_label = QLabel()
|
||||||
self.duplicate_scan_count_label.setStyleSheet("color: #3498db; font-weight: bold;")
|
self.duplicate_scan_count_label.setStyleSheet(
|
||||||
|
"color: #3498db; font-weight: bold;")
|
||||||
self.duplicate_scan_progress = QProgressBar()
|
self.duplicate_scan_progress = QProgressBar()
|
||||||
self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode
|
self.duplicate_scan_progress.setRange(0, 0) # Indeterminate mode
|
||||||
self.duplicate_scan_progress.setFixedHeight(10)
|
self.duplicate_scan_progress.setFixedHeight(10)
|
||||||
@@ -517,20 +524,27 @@ class SettingsDialog(QDialog):
|
|||||||
self.count_update_timer.setInterval(500)
|
self.count_update_timer.setInterval(500)
|
||||||
self.count_update_timer.timeout.connect(self.update_duplicate_scan_count)
|
self.count_update_timer.timeout.connect(self.update_duplicate_scan_count)
|
||||||
|
|
||||||
self.duplicate_whitelist_list.model().rowsInserted.connect(lambda *args: self.count_update_timer.start())
|
self.duplicate_whitelist_list.model().rowsInserted.connect(
|
||||||
self.duplicate_whitelist_list.model().rowsRemoved.connect(lambda *args: self.count_update_timer.start())
|
lambda *args: self.count_update_timer.start())
|
||||||
self.duplicate_blacklist_list.model().rowsInserted.connect(lambda *args: self.count_update_timer.start())
|
self.duplicate_whitelist_list.model().rowsRemoved.connect(
|
||||||
self.duplicate_blacklist_list.model().rowsRemoved.connect(lambda *args: self.count_update_timer.start())
|
lambda *args: self.count_update_timer.start())
|
||||||
|
self.duplicate_blacklist_list.model().rowsInserted.connect(
|
||||||
|
lambda *args: self.count_update_timer.start())
|
||||||
|
self.duplicate_blacklist_list.model().rowsRemoved.connect(
|
||||||
|
lambda *args: self.count_update_timer.start())
|
||||||
|
|
||||||
self.default_delete_to_trash_checkbox = QCheckBox(UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL)
|
self.default_delete_to_trash_checkbox = QCheckBox(
|
||||||
self.default_delete_to_trash_checkbox.setToolTip(UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP)
|
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_LABEL)
|
||||||
|
self.default_delete_to_trash_checkbox.setToolTip(
|
||||||
|
UITexts.SETTINGS_DEFAULT_DELETE_TO_TRASH_TOOLTIP)
|
||||||
duplicates_layout.addWidget(self.default_delete_to_trash_checkbox)
|
duplicates_layout.addWidget(self.default_delete_to_trash_checkbox)
|
||||||
|
|
||||||
|
|
||||||
duplicates_layout.addLayout(threshold_layout)
|
duplicates_layout.addLayout(threshold_layout)
|
||||||
|
|
||||||
self.duplicate_confirm_delete_checkbox = QCheckBox(UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL)
|
self.duplicate_confirm_delete_checkbox = QCheckBox(
|
||||||
self.duplicate_confirm_delete_checkbox.setToolTip(UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP)
|
UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_LABEL)
|
||||||
|
self.duplicate_confirm_delete_checkbox.setToolTip(
|
||||||
|
UITexts.SETTINGS_DUPLICATE_CONFIRM_DELETE_TOOLTIP)
|
||||||
duplicates_layout.addWidget(self.duplicate_confirm_delete_checkbox)
|
duplicates_layout.addWidget(self.duplicate_confirm_delete_checkbox)
|
||||||
|
|
||||||
duplicates_layout.addStretch()
|
duplicates_layout.addStretch()
|
||||||
@@ -945,10 +959,12 @@ class SettingsDialog(QDialog):
|
|||||||
duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True)
|
duplicate_confirm_delete = APP_CONFIG.get("duplicate_confirm_delete", True)
|
||||||
self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete)
|
self.duplicate_confirm_delete_checkbox.setChecked(duplicate_confirm_delete)
|
||||||
|
|
||||||
duplicate_whitelist = APP_CONFIG.get("duplicate_whitelist", SCANNER_SETTINGS_DEFAULTS["duplicate_whitelist"])
|
duplicate_whitelist = APP_CONFIG.get(
|
||||||
|
"duplicate_whitelist", SCANNER_SETTINGS_DEFAULTS["duplicate_whitelist"])
|
||||||
for p in [x.strip() for x in duplicate_whitelist.split(",") if x.strip()]:
|
for p in [x.strip() for x in duplicate_whitelist.split(",") if x.strip()]:
|
||||||
self._add_path_to_list(self.duplicate_whitelist_list, p)
|
self._add_path_to_list(self.duplicate_whitelist_list, p)
|
||||||
duplicate_blacklist = APP_CONFIG.get("duplicate_blacklist", SCANNER_SETTINGS_DEFAULTS["duplicate_blacklist"])
|
duplicate_blacklist = APP_CONFIG.get(
|
||||||
|
"duplicate_blacklist", SCANNER_SETTINGS_DEFAULTS["duplicate_blacklist"])
|
||||||
for p in [x.strip() for x in duplicate_blacklist.split(",") if x.strip()]:
|
for p in [x.strip() for x in duplicate_blacklist.split(",") if x.strip()]:
|
||||||
self._add_path_to_list(self.duplicate_blacklist_list, p)
|
self._add_path_to_list(self.duplicate_blacklist_list, p)
|
||||||
|
|
||||||
@@ -1286,11 +1302,15 @@ class SettingsDialog(QDialog):
|
|||||||
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value()
|
APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value()
|
||||||
APP_CONFIG["duplicate_method"] = self.duplicate_method_combo.currentData()
|
APP_CONFIG["duplicate_method"] = self.duplicate_method_combo.currentData()
|
||||||
APP_CONFIG["duplicate_threshold"] = self.duplicate_threshold_slider.value()
|
APP_CONFIG["duplicate_threshold"] = self.duplicate_threshold_slider.value()
|
||||||
APP_CONFIG["default_delete_to_trash"] = self.default_delete_to_trash_checkbox.isChecked()
|
APP_CONFIG["default_delete_to_trash"] = \
|
||||||
APP_CONFIG["duplicate_confirm_delete"] = self.duplicate_confirm_delete_checkbox.isChecked()
|
self.default_delete_to_trash_checkbox.isChecked()
|
||||||
wl_paths = [self.duplicate_whitelist_list.item(i).text() for i in range(self.duplicate_whitelist_list.count())]
|
APP_CONFIG["duplicate_confirm_delete"] = \
|
||||||
|
self.duplicate_confirm_delete_checkbox.isChecked()
|
||||||
|
wl_paths = [self.duplicate_whitelist_list.item(i).text()
|
||||||
|
for i in range(self.duplicate_whitelist_list.count())]
|
||||||
APP_CONFIG["duplicate_whitelist"] = ",".join(wl_paths)
|
APP_CONFIG["duplicate_whitelist"] = ",".join(wl_paths)
|
||||||
bl_paths = [self.duplicate_blacklist_list.item(i).text() for i in range(self.duplicate_blacklist_list.count())]
|
bl_paths = [self.duplicate_blacklist_list.item(i).text()
|
||||||
|
for i in range(self.duplicate_blacklist_list.count())]
|
||||||
APP_CONFIG["duplicate_blacklist"] = ",".join(bl_paths)
|
APP_CONFIG["duplicate_blacklist"] = ",".join(bl_paths)
|
||||||
|
|
||||||
APP_CONFIG["viewer_auto_resize_window"] = \
|
APP_CONFIG["viewer_auto_resize_window"] = \
|
||||||
@@ -1381,7 +1401,8 @@ class SettingsDialog(QDialog):
|
|||||||
item = QListWidgetItem(path)
|
item = QListWidgetItem(path)
|
||||||
if not os.path.isdir(path):
|
if not os.path.isdir(path):
|
||||||
item.setForeground(QColor("red"))
|
item.setForeground(QColor("red"))
|
||||||
item.setToolTip(f"Warning: Path not found or is not a directory: {path}")
|
item.setToolTip(
|
||||||
|
UITexts.SETTINGS_PATH_NOT_FOUND_WARNING.format(path))
|
||||||
list_widget.addItem(item)
|
list_widget.addItem(item)
|
||||||
|
|
||||||
def add_whitelist_path(self):
|
def add_whitelist_path(self):
|
||||||
@@ -1393,7 +1414,8 @@ class SettingsDialog(QDialog):
|
|||||||
def remove_whitelist_path(self):
|
def remove_whitelist_path(self):
|
||||||
"""Removes the selected folders from the whitelist list."""
|
"""Removes the selected folders from the whitelist list."""
|
||||||
for item in self.duplicate_whitelist_list.selectedItems():
|
for item in self.duplicate_whitelist_list.selectedItems():
|
||||||
self.duplicate_whitelist_list.takeItem(self.duplicate_whitelist_list.row(item))
|
self.duplicate_whitelist_list.takeItem(
|
||||||
|
self.duplicate_whitelist_list.row(item))
|
||||||
|
|
||||||
def add_blacklist_path(self):
|
def add_blacklist_path(self):
|
||||||
"""Opens a directory dialog to add a folder to the blacklist."""
|
"""Opens a directory dialog to add a folder to the blacklist."""
|
||||||
@@ -1404,10 +1426,12 @@ class SettingsDialog(QDialog):
|
|||||||
def remove_blacklist_path(self):
|
def remove_blacklist_path(self):
|
||||||
"""Removes the selected folders from the blacklist list."""
|
"""Removes the selected folders from the blacklist list."""
|
||||||
for item in self.duplicate_blacklist_list.selectedItems():
|
for item in self.duplicate_blacklist_list.selectedItems():
|
||||||
self.duplicate_blacklist_list.takeItem(self.duplicate_blacklist_list.row(item))
|
self.duplicate_blacklist_list.takeItem(
|
||||||
|
self.duplicate_blacklist_list.row(item))
|
||||||
|
|
||||||
def update_duplicate_scan_count(self):
|
def update_duplicate_scan_count(self):
|
||||||
"""Calculates and updates the count of images in whitelist/blacklist using a background thread."""
|
"""Calculates and updates the count of images in whitelist/blacklist
|
||||||
|
using a background thread."""
|
||||||
if self.counter_thread and self.counter_thread.isRunning():
|
if self.counter_thread and self.counter_thread.isRunning():
|
||||||
self.counter_thread.stop()
|
self.counter_thread.stop()
|
||||||
self.counter_thread.wait()
|
self.counter_thread.wait()
|
||||||
@@ -1417,17 +1441,23 @@ class SettingsDialog(QDialog):
|
|||||||
blacklist_paths = [self.duplicate_blacklist_list.item(i).text()
|
blacklist_paths = [self.duplicate_blacklist_list.item(i).text()
|
||||||
for i in range(self.duplicate_blacklist_list.count())]
|
for i in range(self.duplicate_blacklist_list.count())]
|
||||||
|
|
||||||
whitelist = [os.path.abspath(os.path.expanduser(p.strip())) for p in whitelist_paths if p.strip()]
|
whitelist = [os.path.abspath(os.path.expanduser(p.strip()))
|
||||||
blacklist = {os.path.abspath(os.path.expanduser(p.strip())) for p in blacklist_paths if p.strip()}
|
for p in whitelist_paths if p.strip()]
|
||||||
|
blacklist = {os.path.abspath(os.path.expanduser(p.strip()))
|
||||||
|
for p in blacklist_paths if p.strip()}
|
||||||
|
|
||||||
if not whitelist:
|
if not whitelist:
|
||||||
self.duplicate_scan_count_label.setText(UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0))
|
self.duplicate_scan_count_label.setText(
|
||||||
|
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(0))
|
||||||
self.duplicate_scan_progress.hide()
|
self.duplicate_scan_progress.hide()
|
||||||
return
|
return
|
||||||
|
|
||||||
self.duplicate_scan_progress.show()
|
self.duplicate_scan_progress.show()
|
||||||
self.counter_thread = DuplicateFileCounter(whitelist, blacklist, IMAGE_EXTENSIONS)
|
self.counter_thread = DuplicateFileCounter(
|
||||||
|
whitelist, blacklist, IMAGE_EXTENSIONS)
|
||||||
self.counter_thread.count_updated.connect(
|
self.counter_thread.count_updated.connect(
|
||||||
lambda c: self.duplicate_scan_count_label.setText(UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c)))
|
lambda c: self.duplicate_scan_count_label.setText(
|
||||||
self.counter_thread.finished.connect(lambda: self.duplicate_scan_progress.hide())
|
UITexts.SETTINGS_DUPLICATE_SCAN_COUNT_LABEL.format(c)))
|
||||||
|
self.counter_thread.finished.connect(
|
||||||
|
lambda: self.duplicate_scan_progress.hide())
|
||||||
self.counter_thread.start()
|
self.counter_thread.start()
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="bagheeraview",
|
name="bagheeraview",
|
||||||
version="0.9.18",
|
version="0.9.19",
|
||||||
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 "
|
||||||
|
|||||||
Reference in New Issue
Block a user