From 36aaadca1dfb3585b8fbcc516157003dce29be84 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 9 Oct 2025 12:43:28 -0400 Subject: [PATCH] Add folder browsing and path validation features to Dashboard GUI and photo management This commit introduces a folder browsing button in the Dashboard GUI, allowing users to select a folder for photo scanning. It also implements path normalization and validation using new utility functions from the path_utils module, ensuring that folder paths are absolute and accessible before scanning. Additionally, the PhotoManager class has been updated to utilize these path utilities, enhancing the robustness of folder scanning operations. This improves user experience by preventing errors related to invalid paths and streamlining folder management across the application. --- dashboard_gui.py | 22 +++++- path_utils.py | 174 ++++++++++++++++++++++++++++++++++++++++++++ photo_management.py | 17 ++++- photo_tagger.py | 11 ++- search_gui.py | 22 ++++-- search_stats.py | 4 +- 6 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 path_utils.py diff --git a/dashboard_gui.py b/dashboard_gui.py index 4772b8b..959ef69 100644 --- a/dashboard_gui.py +++ b/dashboard_gui.py @@ -76,6 +76,16 @@ class DashboardGUI: folder_var = tk.StringVar() folder_entry = ttk.Entry(folder_row, textvariable=folder_var) folder_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) + + # Browse button for folder selection + def browse_folder(): + from tkinter import filedialog + folder_path = filedialog.askdirectory(title="Select folder to scan for photos") + if folder_path: + folder_var.set(folder_path) + + browse_btn = ttk.Button(folder_row, text="Browse", command=browse_folder) + browse_btn.pack(side=tk.LEFT, padx=(6, 0)) # Recursive checkbox recursive_var = tk.BooleanVar(value=True) ttk.Checkbutton( @@ -93,8 +103,16 @@ class DashboardGUI: if not folder: messagebox.showwarning("Scan", "Please enter a folder path.", parent=root) return - if not os.path.isdir(folder): - messagebox.showerror("Scan", "Folder does not exist.", parent=root) + + # Validate folder path using path utilities + from path_utils import validate_path_exists, normalize_path + try: + folder = normalize_path(folder) + if not validate_path_exists(folder): + messagebox.showerror("Scan", f"Folder does not exist or is not accessible: {folder}", parent=root) + return + except ValueError as e: + messagebox.showerror("Scan", f"Invalid folder path: {e}", parent=root) return if not callable(self.on_scan): messagebox.showinfo("Scan", "Scan is not wired yet.", parent=root) diff --git a/path_utils.py b/path_utils.py new file mode 100644 index 0000000..ed00c41 --- /dev/null +++ b/path_utils.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +Path utility functions for PunimTag +Ensures all paths are stored as absolute paths for consistency +""" + +import os +from pathlib import Path +from typing import Union + + +def normalize_path(path: Union[str, Path]) -> str: + """ + Convert any path to an absolute path. + + Args: + path: Path to normalize (can be relative or absolute) + + Returns: + Absolute path as string + + Examples: + normalize_path("demo_photos") -> "/home/user/punimtag/demo_photos" + normalize_path("./photos") -> "/home/user/punimtag/photos" + normalize_path("/absolute/path") -> "/absolute/path" + """ + if not path: + raise ValueError("Path cannot be empty or None") + + # Convert to string if Path object + path_str = str(path) + + # Use pathlib for robust path resolution + normalized = Path(path_str).resolve() + + return str(normalized) + + +def is_absolute_path(path: Union[str, Path]) -> bool: + """ + Check if a path is absolute. + + Args: + path: Path to check + + Returns: + True if path is absolute, False if relative + """ + return Path(path).is_absolute() + + +def is_relative_path(path: Union[str, Path]) -> bool: + """ + Check if a path is relative. + + Args: + path: Path to check + + Returns: + True if path is relative, False if absolute + """ + return not Path(path).is_absolute() + + +def validate_path_exists(path: Union[str, Path]) -> bool: + """ + Check if a path exists and is accessible. + + Args: + path: Path to validate + + Returns: + True if path exists and is accessible, False otherwise + """ + try: + normalized = normalize_path(path) + return os.path.exists(normalized) and os.access(normalized, os.R_OK) + except (ValueError, OSError): + return False + + +def get_path_info(path: Union[str, Path]) -> dict: + """ + Get detailed information about a path. + + Args: + path: Path to analyze + + Returns: + Dictionary with path information + """ + try: + normalized = normalize_path(path) + path_obj = Path(normalized) + + return { + 'original': str(path), + 'normalized': normalized, + 'is_absolute': path_obj.is_absolute(), + 'is_relative': not path_obj.is_absolute(), + 'exists': path_obj.exists(), + 'is_file': path_obj.is_file(), + 'is_dir': path_obj.is_dir(), + 'parent': str(path_obj.parent), + 'name': path_obj.name, + 'stem': path_obj.stem, + 'suffix': path_obj.suffix + } + except Exception as e: + return { + 'original': str(path), + 'error': str(e) + } + + +def ensure_directory_exists(path: Union[str, Path]) -> str: + """ + Ensure a directory exists, creating it if necessary. + + Args: + path: Directory path to ensure exists + + Returns: + Absolute path to the directory + """ + normalized = normalize_path(path) + Path(normalized).mkdir(parents=True, exist_ok=True) + return normalized + + +def get_relative_path_from_base(absolute_path: Union[str, Path], base_path: Union[str, Path]) -> str: + """ + Get relative path from a base directory. + + Args: + absolute_path: Absolute path to convert + base_path: Base directory to make relative to + + Returns: + Relative path from base directory + """ + abs_path = normalize_path(absolute_path) + base = normalize_path(base_path) + + try: + return str(Path(abs_path).relative_to(base)) + except ValueError: + # If paths don't share a common base, return the absolute path + return abs_path + + +# Test the utility functions +if __name__ == "__main__": + print("=== Path Utility Functions Test ===") + + test_paths = [ + "demo_photos", + "./demo_photos", + "../demo_photos", + "/home/ladmin/Code/punimtag/demo_photos", + "C:/Users/Test/Photos", + "/tmp/test" + ] + + for path in test_paths: + print(f"\nTesting: {path}") + try: + normalized = normalize_path(path) + info = get_path_info(path) + print(f" Normalized: {normalized}") + print(f" Exists: {info['exists']}") + print(f" Is directory: {info['is_dir']}") + except Exception as e: + print(f" Error: {e}") diff --git a/photo_management.py b/photo_management.py index c1c06d9..108e533 100644 --- a/photo_management.py +++ b/photo_management.py @@ -11,6 +11,7 @@ from typing import Optional, List, Tuple from config import SUPPORTED_IMAGE_FORMATS from database import DatabaseManager +from path_utils import normalize_path, validate_path_exists class PhotoManager: @@ -58,8 +59,15 @@ class PhotoManager: def scan_folder(self, folder_path: str, recursive: bool = True) -> int: """Scan folder for photos and add to database""" - if not os.path.exists(folder_path): - print(f"❌ Folder not found: {folder_path}") + # Normalize path to absolute path + try: + folder_path = normalize_path(folder_path) + except ValueError as e: + print(f"❌ Invalid path: {e}") + return 0 + + if not validate_path_exists(folder_path): + print(f"❌ Folder not found or not accessible: {folder_path}") return 0 found_photos = [] @@ -88,10 +96,13 @@ class PhotoManager: for photo_path, filename in found_photos: try: + # Ensure photo path is absolute + photo_path = normalize_path(photo_path) + # Extract date taken from EXIF data date_taken = self.extract_photo_date(photo_path) - # Add photo to database + # Add photo to database (with absolute path) photo_id = self.db.add_photo(photo_path, filename, date_taken) if photo_id: # New photo was added diff --git a/photo_tagger.py b/photo_tagger.py index 91790c4..0c3c8b7 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -382,7 +382,16 @@ Examples: if not args.target: print("❌ Please specify a folder to scan") return 1 - tagger.scan_folder(args.target, args.recursive) + + # Normalize path to absolute path + from path_utils import normalize_path + try: + normalized_path = normalize_path(args.target) + print(f"📁 Scanning folder: {normalized_path}") + tagger.scan_folder(normalized_path, args.recursive) + except ValueError as e: + print(f"❌ Invalid path: {e}") + return 1 elif args.command == 'process': tagger.process_faces(args.limit, args.model) diff --git a/search_gui.py b/search_gui.py index 4257c78..6769ae5 100644 --- a/search_gui.py +++ b/search_gui.py @@ -127,9 +127,15 @@ class SearchGUI: # Browse button for folder selection def browse_folder(): from tkinter import filedialog + from path_utils import normalize_path folder_path = filedialog.askdirectory(title="Select folder to filter by") if folder_path: - folder_var.set(folder_path) + try: + # Normalize to absolute path + normalized_path = normalize_path(folder_path) + folder_var.set(normalized_path) + except ValueError as e: + messagebox.showerror("Invalid Path", f"Invalid folder path: {e}", parent=root) browse_btn = ttk.Button(folder_filter_frame, text="Browse", command=browse_folder) browse_btn.pack(side=tk.LEFT, padx=(6, 0)) @@ -140,6 +146,10 @@ class SearchGUI: clear_folder_btn = ttk.Button(folder_filter_frame, text="Clear", command=clear_folder_filter) clear_folder_btn.pack(side=tk.LEFT, padx=(6, 0)) + + # Apply filters button + apply_filters_btn = ttk.Button(filters_content, text="Apply filters", command=lambda: do_search()) + apply_filters_btn.pack(pady=(8, 0)) # Inputs area inputs = ttk.Frame(main) @@ -428,9 +438,9 @@ class SearchGUI: filtered_results = [] for result in results: - if len(result) >= 2: - # Extract photo path from result tuple - photo_path = result[1] if len(result) == 2 else result[0] + if len(result) >= 1: + # Extract photo path from result tuple (always at index 0) + photo_path = result[0] # Check if photo path starts with the specified folder path if photo_path.startswith(folder_path): @@ -479,8 +489,8 @@ class SearchGUI: date_taken = get_photo_date_taken(path) tree.insert("", tk.END, values=("☐", "", photo_tags, "📁", "👤", path, date_taken)) else: - # For name search: (full_name, path) - show person column - full_name, p = row + # For name search: (path, full_name) - show person column + p, full_name = row # Get tags for this photo photo_tags = get_photo_tags_for_display(p) date_taken = get_photo_date_taken(p) diff --git a/search_stats.py b/search_stats.py index c3928b0..296008a 100644 --- a/search_stats.py +++ b/search_stats.py @@ -19,7 +19,7 @@ class SearchStats: def search_faces(self, person_name: str) -> List[Tuple[str, str]]: """Search for photos containing a specific person by name (partial, case-insensitive). - Returns a list of tuples: (person_full_name, photo_path). + Returns a list of tuples: (photo_path, person_full_name). """ # Get all people matching the name people = self.db.show_people_list() @@ -70,7 +70,7 @@ class SearchStats: first = (row[1] or "").strip() last = (row[2] or "").strip() full_name = (f"{first} {last}").strip() or "Unknown" - results.append((full_name, path)) + results.append((path, full_name)) except Exception: # Fall back gracefully if schema differs pass