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.
This commit is contained in:
tanyar09 2025-10-09 12:43:28 -04:00
parent 150ae5fd3f
commit 36aaadca1d
6 changed files with 236 additions and 14 deletions

View File

@ -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)

174
path_utils.py Normal file
View File

@ -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}")

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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