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