diff --git a/README.md b/README.md index 73ca342..1a9b776 100644 --- a/README.md +++ b/README.md @@ -351,8 +351,18 @@ python3 photo_tagger.py search "John" # Find photos with partial name match python3 photo_tagger.py search "Joh" + +# Open the Search GUI +python3 photo_tagger.py search-gui ``` +Search GUI features: +- Person column shows the matched person's full name +- 📁 column: click to open the file's folder (tooltip: "Open file location") +- 📷 column: click to open the photo (tooltip: "Open photo") +- Path column shows the absolute photo path +- Press Enter in the name field to trigger search + ### Statistics ```bash # View database statistics @@ -914,6 +924,7 @@ python3 photo_tagger.py scan ~/Pictures --recursive python3 photo_tagger.py process --limit 50 python3 photo_tagger.py identify --show-faces --batch 10 # Opens GUI python3 photo_tagger.py auto-match --show-faces # Opens GUI +python3 photo_tagger.py search-gui # Opens Search GUI python3 photo_tagger.py modifyidentified # Opens GUI to view/modify python3 photo_tagger.py tag-manager # Opens GUI for tag management python3 photo_tagger.py stats diff --git a/photo_tagger.py b/photo_tagger.py index ec9995a..37336c5 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -25,6 +25,7 @@ from identify_gui import IdentifyGUI from auto_match_gui import AutoMatchGUI from modify_identified_gui import ModifyIdentifiedGUI from tag_manager_gui import TagManagerGUI +from search_gui import SearchGUI class PhotoTagger: @@ -47,6 +48,7 @@ class PhotoTagger: self.auto_match_gui = AutoMatchGUI(self.db, self.face_processor, verbose) self.modify_identified_gui = ModifyIdentifiedGUI(self.db, self.face_processor, verbose) self.tag_manager_gui = TagManagerGUI(self.db, self.gui_core, self.tag_manager, self.face_processor, verbose) + self.search_gui = SearchGUI(self.db, self.search_stats, self.gui_core, verbose) # Legacy compatibility - expose some methods directly self._db_connection = None @@ -188,6 +190,10 @@ class PhotoTagger: def modifyidentified(self) -> int: return self.modify_identified_gui.modifyidentified() + def searchgui(self) -> int: + """Open the Search GUI.""" + return self.search_gui.search_gui() + def _setup_window_size_saving(self, root, config_file="gui_config.json"): """Set up window size saving functionality (legacy compatibility)""" return self.gui_core.setup_window_size_saving(root, config_file) @@ -285,7 +291,7 @@ Examples: ) parser.add_argument('command', - choices=['scan', 'process', 'identify', 'tag', 'search', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'], + choices=['scan', 'process', 'identify', 'tag', 'search', 'search-gui', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'], help='Command to execute') parser.add_argument('target', nargs='?', @@ -369,6 +375,9 @@ Examples: return 1 tagger.search_faces(args.target) + elif args.command == 'search-gui': + tagger.searchgui() + elif args.command == 'stats': tagger.stats() diff --git a/search_gui.py b/search_gui.py new file mode 100644 index 0000000..def7832 --- /dev/null +++ b/search_gui.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +Search GUI implementation for PunimTag +""" + +import os +import sys +import tkinter as tk +from tkinter import ttk, messagebox +from typing import List + +from gui_core import GUICore +from search_stats import SearchStats +from database import DatabaseManager + + +class SearchGUI: + """GUI for searching photos by different criteria.""" + + SEARCH_TYPES = [ + "Search photos by name", + "Search photos by date (planned)", + "Search photos by tags (planned)", + "Search photos by multiple people (planned)", + "Most common tags (planned)", + "Most photographed people (planned)", + "Photos without faces (planned)", + "Photos without tags (planned)", + "Duplicate faces (planned)", + "Face quality distribution (planned)", + ] + + def __init__(self, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, verbose: int = 0): + self.db = db_manager + self.search_stats = search_stats + self.gui_core = gui_core + self.verbose = verbose + + def search_gui(self) -> int: + """Open the Search GUI window.""" + root = tk.Tk() + root.title("Search Photos") + root.resizable(True, True) + + # Hide to center and size + root.withdraw() + + main = ttk.Frame(root, padding="10") + main.pack(fill=tk.BOTH, expand=True) + + # Search type selector + type_frame = ttk.Frame(main) + type_frame.pack(fill=tk.X) + ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).pack(side=tk.LEFT) + search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0]) + type_combo = ttk.Combobox(type_frame, textvariable=search_type_var, values=self.SEARCH_TYPES, state="readonly") + type_combo.pack(side=tk.LEFT, padx=(8, 0), fill=tk.X, expand=True) + + # Inputs area + inputs = ttk.Frame(main) + inputs.pack(fill=tk.X, pady=(10, 6)) + + # Name search input + name_frame = ttk.Frame(inputs) + ttk.Label(name_frame, text="Person name:").pack(side=tk.LEFT) + name_var = tk.StringVar() + name_entry = ttk.Entry(name_frame, textvariable=name_var) + name_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) + + # Planned inputs (stubs) + planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888") + + # Results area + results_frame = ttk.Frame(main) + results_frame.pack(fill=tk.BOTH, expand=True) + ttk.Label(results_frame, text="Results:", font=("Arial", 10, "bold")).pack(anchor="w") + + columns = ("person", "open_dir", "open_photo", "path") + tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse") + tree.heading("person", text="Person") + tree.heading("open_dir", text="📁") + tree.heading("open_photo", text="📷") + tree.heading("path", text="Photo path") + tree.column("person", width=220, anchor="w") + tree.column("open_dir", width=50, anchor="center") + tree.column("open_photo", width=50, anchor="center") + tree.column("path", width=520, anchor="w") + tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0)) + + # Buttons + btns = ttk.Frame(main) + btns.pack(fill=tk.X, pady=(8, 0)) + search_btn = ttk.Button(btns, text="Search", command=lambda: do_search()) + search_btn.pack(side=tk.LEFT) + open_btn = ttk.Button(btns, text="Open Photo", command=lambda: open_selected()) + open_btn.pack(side=tk.LEFT, padx=(6, 0)) + close_btn = ttk.Button(btns, text="Close", command=lambda: root.destroy()) + close_btn.pack(side=tk.RIGHT) + + # Behavior + def switch_inputs(*_): + for w in inputs.winfo_children(): + w.pack_forget() + choice = search_type_var.get() + if choice == self.SEARCH_TYPES[0]: + name_frame.pack(fill=tk.X) + name_entry.configure(state="normal") + search_btn.configure(state="normal") + else: + planned_label.pack(anchor="w") + name_entry.configure(state="disabled") + search_btn.configure(state="disabled") + + def clear_results(): + for i in tree.get_children(): + tree.delete(i) + + def add_results(rows: List[tuple]): + # rows expected: List[(full_name, path)] + for full_name, p in rows: + tree.insert("", tk.END, values=(full_name, "📁", "📷", p)) + + def do_search(): + clear_results() + choice = search_type_var.get() + if choice == self.SEARCH_TYPES[0]: + query = name_var.get().strip() + if not query: + messagebox.showinfo("Search", "Please enter a name to search.", parent=root) + return + rows = self.search_stats.search_faces(query) + if not rows: + messagebox.showinfo("Search", f"No photos found for '{query}'.", parent=root) + add_results(rows) + + def open_selected(): + sel = tree.selection() + if not sel: + return + vals = tree.item(sel[0], "values") + path = vals[3] if len(vals) >= 4 else None + try: + if os.name == "nt": + os.startfile(path) # type: ignore[attr-defined] + elif sys.platform == "darwin": + import subprocess + subprocess.run(["open", path], check=False) + else: + import subprocess + subprocess.run(["xdg-open", path], check=False) + except Exception: + messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root) + + def open_dir(path: str): + try: + folder = os.path.dirname(path) + if os.name == "nt": + os.startfile(folder) # type: ignore[attr-defined] + elif sys.platform == "darwin": + import subprocess + subprocess.run(["open", folder], check=False) + else: + import subprocess + subprocess.run(["xdg-open", folder], check=False) + except Exception: + messagebox.showerror("Open Location", "Failed to open the file location.", parent=root) + + # Click handling on icon columns + def on_tree_click(event): + region = tree.identify("region", event.x, event.y) + if region != "cell": + return + row_id = tree.identify_row(event.y) + col_id = tree.identify_column(event.x) # '#1', '#2', ... + if not row_id or not col_id: + return + vals = tree.item(row_id, "values") + if not vals or len(vals) < 4: + return + path = vals[3] + if col_id == "#2": + open_dir(path) + elif col_id == "#3": + try: + if os.name == "nt": + os.startfile(path) # type: ignore[attr-defined] + elif sys.platform == "darwin": + import subprocess + subprocess.run(["open", path], check=False) + else: + import subprocess + subprocess.run(["xdg-open", path], check=False) + except Exception: + messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root) + + # Tooltip for icon cells + tooltip = None + def show_tooltip(widget, x, y, text: str): + nonlocal tooltip + hide_tooltip() + try: + tooltip = tk.Toplevel(widget) + tooltip.wm_overrideredirect(True) + tooltip.wm_geometry(f"+{x+12}+{y+12}") + lbl = tk.Label(tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9)) + lbl.pack() + except Exception: + tooltip = None + + def hide_tooltip(*_): + nonlocal tooltip + if tooltip is not None: + try: + tooltip.destroy() + except Exception: + pass + tooltip = None + + def on_tree_motion(event): + region = tree.identify("region", event.x, event.y) + if region != "cell": + hide_tooltip() + tree.config(cursor="") + return + col_id = tree.identify_column(event.x) + if col_id == "#2": + tree.config(cursor="hand2") + show_tooltip(tree, event.x_root, event.y_root, "Open file location") + elif col_id == "#3": + tree.config(cursor="hand2") + show_tooltip(tree, event.x_root, event.y_root, "Open photo") + else: + tree.config(cursor="") + hide_tooltip() + + type_combo.bind("<>", switch_inputs) + switch_inputs() + tree.bind("", on_tree_click) + tree.bind("", on_tree_motion) + tree.bind("", hide_tooltip) + + # Enter key in name field triggers search + name_entry.bind("", lambda e: do_search()) + + # Show and center + root.update_idletasks() + self.gui_core.center_window(root, 800, 500) + root.deiconify() + root.mainloop() + return 0 + + diff --git a/search_stats.py b/search_stats.py index da0f6d7..f234085 100644 --- a/search_stats.py +++ b/search_stats.py @@ -16,44 +16,66 @@ class SearchStats: self.db = db_manager self.verbose = verbose - def search_faces(self, person_name: str) -> List[str]: - """Search for photos containing a specific person""" + 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). + """ # Get all people matching the name people = self.db.show_people_list() matching_people = [] + search_name = (person_name or "").strip().lower() + if not search_name: + return [] + for person in people: person_id, first_name, last_name, middle_name, maiden_name, date_of_birth, created_date = person - full_name = f"{first_name} {last_name}".lower() - search_name = person_name.lower() + full_name = f"{first_name or ''} {last_name or ''}".strip().lower() # Check if search term matches any part of the name - if (search_name in full_name or - search_name in first_name.lower() or - search_name in last_name.lower() or + if ( + (full_name and search_name in full_name) or + (first_name and search_name in first_name.lower()) or + (last_name and search_name in last_name.lower()) or (middle_name and search_name in middle_name.lower()) or - (maiden_name and search_name in maiden_name.lower())): + (maiden_name and search_name in maiden_name.lower()) + ): matching_people.append(person_id) if not matching_people: - print(f"❌ No people found matching '{person_name}'") return [] - # Get photos for matching people - photo_paths = [] - for person_id in matching_people: - # This would need to be implemented in the database module - # For now, we'll use a placeholder + # Fetch photo paths for each matching person using database helper if available + results: List[Tuple[str, str]] = [] + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + # faces.person_id links to photos via faces.photo_id + placeholders = ",".join(["?"] * len(matching_people)) + cursor.execute( + f""" + SELECT DISTINCT p.path, pe.first_name, pe.last_name + FROM faces f + JOIN photos p ON p.id = f.photo_id + JOIN people pe ON pe.id = f.person_id + WHERE f.person_id IN ({placeholders}) + ORDER BY pe.last_name, pe.first_name, p.path + """, + tuple(matching_people), + ) + for row in cursor.fetchall(): + if row and row[0]: + path = row[0] + first = (row[1] or "").strip() + last = (row[2] or "").strip() + full_name = (f"{first} {last}").strip() or "Unknown" + results.append((full_name, path)) + except Exception: + # Fall back gracefully if schema differs pass - if photo_paths: - print(f"🔍 Found {len(photo_paths)} photos with '{person_name}':") - for path in photo_paths: - print(f" 📸 {path}") - else: - print(f"❌ No photos found for '{person_name}'") - - return photo_paths + return results def get_statistics(self) -> Dict: """Get comprehensive database statistics"""