diff --git a/search_gui.py b/search_gui.py index 7962c62..30d02af 100644 --- a/search_gui.py +++ b/search_gui.py @@ -21,7 +21,7 @@ class SearchGUI: SEARCH_TYPES = [ "Search photos by name", "Search photos by date (planned)", - "Search photos by tags (planned)", + "Search photos by tags", "Search photos by multiple people (planned)", "Most common tags (planned)", "Most photographed people (planned)", @@ -44,6 +44,9 @@ class SearchGUI: # Selection tracking self.selected_photos = {} # photo_path -> photo_data + + # Cache for photo tags to avoid database access during updates + self.photo_tags_cache = {} # photo_path -> list of tag names def search_gui(self) -> int: """Open the Search GUI window.""" @@ -76,6 +79,23 @@ class SearchGUI: name_entry = ttk.Entry(name_frame, textvariable=name_var) name_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) + # Tag search input + tag_frame = ttk.Frame(inputs) + ttk.Label(tag_frame, text="Tags:").pack(side=tk.LEFT) + tag_var = tk.StringVar() + tag_entry = ttk.Entry(tag_frame, textvariable=tag_var) + tag_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True) + ttk.Label(tag_frame, text="(comma-separated)").pack(side=tk.LEFT, padx=(6, 0)) + + # Tag search mode + tag_mode_frame = ttk.Frame(inputs) + ttk.Label(tag_mode_frame, text="Match mode:").pack(side=tk.LEFT) + tag_mode_var = tk.StringVar(value="ANY") + tag_mode_combo = ttk.Combobox(tag_mode_frame, textvariable=tag_mode_var, + values=["ANY", "ALL"], state="readonly", width=8) + tag_mode_combo.pack(side=tk.LEFT, padx=(6, 0)) + ttk.Label(tag_mode_frame, text="(ANY = photos with any tag, ALL = photos with all tags)").pack(side=tk.LEFT, padx=(6, 0)) + # Planned inputs (stubs) planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888") @@ -84,17 +104,19 @@ class SearchGUI: 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", "select") + columns = ("person", "tags", "open_dir", "open_photo", "path", "select") tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse") tree.heading("person", text="Person", command=lambda: sort_treeview("person")) + tree.heading("tags", text="Tags", command=lambda: sort_treeview("tags")) tree.heading("open_dir", text="📁") - tree.heading("open_photo", text="🏷️") + tree.heading("open_photo", text="👤") tree.heading("path", text="Photo path", command=lambda: sort_treeview("path")) tree.heading("select", text="☑") - tree.column("person", width=220, anchor="w") + tree.column("person", width=180, anchor="w") + tree.column("tags", width=200, 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.column("path", width=400, anchor="w") tree.column("select", width=50, anchor="center") tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0)) @@ -103,8 +125,6 @@ class SearchGUI: 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)) tag_btn = ttk.Button(btns, text="Tag selected photos", command=lambda: tag_selected_photos()) tag_btn.pack(side=tk.LEFT, padx=(6, 0)) clear_btn = ttk.Button(btns, text="Clear all selected", command=lambda: clear_all_selected()) @@ -128,9 +148,9 @@ class SearchGUI: self.sort_column = col # Sort the items - # For person and path columns, sort alphabetically + # For person, tags, and path columns, sort alphabetically # For icon columns, maintain original order - if col in ['person', 'path']: + if col in ['person', 'tags', 'path']: items.sort(key=lambda x: x[0].lower(), reverse=self.sort_reverse) else: # For icon columns, just reverse if clicking same column @@ -148,29 +168,59 @@ class SearchGUI: """Update header display to show sort indicators.""" # Reset all headers tree.heading("person", text="Person") + tree.heading("tags", text="Tags") tree.heading("path", text="Photo path") # Add sort indicator to current sort column if self.sort_column == "person": indicator = " ↓" if self.sort_reverse else " ↑" tree.heading("person", text="Person" + indicator) + elif self.sort_column == "tags": + indicator = " ↓" if self.sort_reverse else " ↑" + tree.heading("tags", text="Tags" + indicator) elif self.sort_column == "path": indicator = " ↓" if self.sort_reverse else " ↑" tree.heading("path", text="Photo path" + indicator) # Behavior def switch_inputs(*_): + # Clear results when search type changes + clear_results() + for w in inputs.winfo_children(): w.pack_forget() choice = search_type_var.get() - if choice == self.SEARCH_TYPES[0]: + if choice == self.SEARCH_TYPES[0]: # Search photos by name name_frame.pack(fill=tk.X) name_entry.configure(state="normal") search_btn.configure(state="normal") + # Show person column for name search + tree.column("person", width=180, minwidth=50, anchor="w") + tree.heading("person", text="Person", command=lambda: sort_treeview("person")) + # Restore all columns to display + tree["displaycolumns"] = ("person", "tags", "open_dir", "open_photo", "path", "select") + elif choice == self.SEARCH_TYPES[2]: # Search photos by tags + tag_frame.pack(fill=tk.X) + tag_mode_frame.pack(fill=tk.X, pady=(4, 0)) + tag_entry.configure(state="normal") + tag_mode_combo.configure(state="readonly") + search_btn.configure(state="normal") + # Hide person column completely for tag search + tree.column("person", width=0, minwidth=0, anchor="w") + tree.heading("person", text="") + # Also hide the column from display + tree["displaycolumns"] = ("tags", "open_dir", "open_photo", "path", "select") else: planned_label.pack(anchor="w") name_entry.configure(state="disabled") + tag_entry.configure(state="disabled") + tag_mode_combo.configure(state="disabled") search_btn.configure(state="disabled") + # Show person column for other search types + tree.column("person", width=180, minwidth=50, anchor="w") + tree.heading("person", text="Person", command=lambda: sort_treeview("person")) + # Restore all columns to display + tree["displaycolumns"] = ("person", "tags", "open_dir", "open_photo", "path", "select") def clear_results(): for i in tree.get_children(): @@ -180,20 +230,39 @@ class SearchGUI: self.sort_reverse = False # Clear selection tracking self.selected_photos.clear() + # Clear tag cache + self.photo_tags_cache.clear() update_header_display() 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, "☐")) + # rows expected: List[(full_name, path)] or List[(path, tag_info)] for tag search + for row in rows: + if len(row) == 2: + if search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search + # For tag search: (path, tag_info) - hide person column + # Show ALL tags for the photo, not just matching ones + path, tag_info = row + photo_tags = get_photo_tags_for_display(path) + tree.insert("", tk.END, values=("", photo_tags, "📁", "👤", path, "☐")) + else: + # For name search: (full_name, path) - show person column + full_name, p = row + # Get tags for this photo + photo_tags = get_photo_tags_for_display(p) + tree.insert("", tk.END, values=(full_name, photo_tags, "📁", "👤", p, "☐")) - # Sort by person name by default when results are first loaded + # Sort by appropriate column by default when results are first loaded if rows and self.sort_column is None: - # Force ascending sort on first load - self.sort_column = "person" + if search_type_var.get() == self.SEARCH_TYPES[2]: # Tag search + # Sort by tags column for tag search + self.sort_column = "tags" + else: + # Sort by person column for name search + self.sort_column = "person" + self.sort_reverse = False # Get all items and sort them directly - items = [(tree.set(child, "person"), child) for child in tree.get_children('')] + items = [(tree.set(child, self.sort_column), child) for child in tree.get_children('')] items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending # Reorder items in treeview for index, (val, child) in enumerate(items): @@ -204,7 +273,7 @@ class SearchGUI: def do_search(): clear_results() choice = search_type_var.get() - if choice == self.SEARCH_TYPES[0]: + if choice == self.SEARCH_TYPES[0]: # Search photos by name query = name_var.get().strip() if not query: messagebox.showinfo("Search", "Please enter a name to search.", parent=root) @@ -213,24 +282,26 @@ class SearchGUI: 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) + elif choice == self.SEARCH_TYPES[2]: # Search photos by tags + tag_query = tag_var.get().strip() + if not tag_query: + messagebox.showinfo("Search", "Please enter tags to search for.", parent=root) + return + + # Parse comma-separated tags + tags = [tag.strip() for tag in tag_query.split(',') if tag.strip()] + if not tags: + messagebox.showinfo("Search", "Please enter valid tags to search for.", parent=root) + return + + # Determine match mode + match_all = (tag_mode_var.get() == "ALL") + + rows = self.search_stats.search_photos_by_tags(tags, match_all) + if not rows: + mode_text = "all" if match_all else "any" + messagebox.showinfo("Search", f"No photos found with {mode_text} of the tags: {', '.join(tags)}", parent=root) + add_results(rows) def open_dir(path: str): try: @@ -248,10 +319,10 @@ class SearchGUI: def toggle_photo_selection(row_id, vals): """Toggle checkbox selection for a photo.""" - if len(vals) < 5: + if len(vals) < 6: return - path = vals[3] - current_state = vals[4] + path = vals[4] # Photo path is now in column 4 + current_state = vals[5] # Checkbox is now in column 5 if current_state == "☐": # Select photo new_state = "☑" @@ -267,7 +338,7 @@ class SearchGUI: # Update the treeview new_vals = list(vals) - new_vals[4] = new_state + new_vals[5] = new_state tree.item(row_id, values=new_vals) def tag_selected_photos(): @@ -304,11 +375,12 @@ class SearchGUI: # Update all checkboxes to unselected state for item in tree.get_children(): vals = tree.item(item, "values") - if len(vals) >= 5 and vals[4] == "☑": + if len(vals) >= 6 and vals[5] == "☑": new_vals = list(vals) - new_vals[4] = "☐" + new_vals[5] = "☐" tree.item(item, values=new_vals) + def show_photo_tags(photo_path): """Show tags for a specific photo in a popup.""" # Get photo ID @@ -376,6 +448,112 @@ class SearchGUI: # Close button ttk.Button(frame, text="Close", command=popup.destroy).pack(pady=(10, 0)) + def get_person_name_for_photo(photo_path): + """Get person name for a photo (if any faces are identified).""" + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT DISTINCT pe.first_name, pe.last_name + FROM photos p + JOIN faces f ON p.id = f.photo_id + JOIN people pe ON f.person_id = pe.id + WHERE p.path = ? AND f.person_id IS NOT NULL + LIMIT 1 + ''', (photo_path,)) + result = cursor.fetchone() + if result: + first = (result[0] or "").strip() + last = (result[1] or "").strip() + return f"{first} {last}".strip() or "Unknown" + except Exception: + pass + return "No person identified" + + def get_photo_tags_for_display(photo_path): + """Get tags for a photo to display in the tags column.""" + # Check cache first + if photo_path in self.photo_tags_cache: + tag_names = self.photo_tags_cache[photo_path] + else: + # Load from database and cache + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,)) + result = cursor.fetchone() + if not result: + return "No photo found" + + photo_id = result[0] + cursor.execute(''' + SELECT t.tag_name + FROM tags t + JOIN phototaglinkage ptl ON t.id = ptl.tag_id + WHERE ptl.photo_id = ? + ORDER BY t.tag_name + ''', (photo_id,)) + tag_names = [row[0] for row in cursor.fetchall()] + self.photo_tags_cache[photo_path] = tag_names + except Exception: + return "No tags" + + # Format for display - show all tags + if tag_names: + return ', '.join(tag_names) + else: + return "No tags" + + def get_photo_people_tooltip(photo_path): + """Get people information for a photo to display in tooltip.""" + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT DISTINCT pe.first_name, pe.last_name, pe.middle_name, pe.maiden_name + FROM photos p + JOIN faces f ON p.id = f.photo_id + JOIN people pe ON f.person_id = pe.id + WHERE p.path = ? AND f.person_id IS NOT NULL + ORDER BY pe.last_name, pe.first_name + ''', (photo_path,)) + people = cursor.fetchall() + + if not people: + return "No people identified" + + people_names = [] + for person in people: + first = (person[0] or "").strip() + last = (person[1] or "").strip() + middle = (person[2] or "").strip() + maiden = (person[3] or "").strip() + + # Build full name + name_parts = [] + if first: + name_parts.append(first) + if middle: + name_parts.append(middle) + if last: + name_parts.append(last) + if maiden and maiden != last: + name_parts.append(f"({maiden})") + + full_name = " ".join(name_parts) if name_parts else "Unknown" + people_names.append(full_name) + + if people_names: + if len(people_names) <= 3: + return f"People: {', '.join(people_names)}" + else: + return f"People: {', '.join(people_names[:3])}... (+{len(people_names)-3} more)" + else: + return "No people identified" + except Exception: + pass + return "No people identified" + def get_photo_tags_tooltip(photo_path): """Get tags for a photo to display in tooltip.""" # Get photo ID @@ -419,6 +597,10 @@ class SearchGUI: popup.grab_set() popup.geometry("500x400") popup.resizable(True, True) + + # Track tag changes for updating results + tags_added = set() # tag names that were added + tags_removed = set() # tag names that were removed top_frame = ttk.Frame(popup, padding="8") top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E)) @@ -497,6 +679,10 @@ class SearchGUI: cursor.execute('INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) VALUES (?, ?, 0)', (photo_id, tag_id)) affected += 1 + # Track that this tag was added + if affected > 0: + tags_added.add(tag_name) + # Refresh the tag list to show the new tag refresh_tag_list() tag_var.set("") @@ -596,9 +782,11 @@ class SearchGUI: def remove_selected_tags(): tag_ids_to_remove = [] + tag_names_to_remove = [] for tag_name, var in selected_tag_vars.items(): if var.get() and tag_name in tag_name_to_id: tag_ids_to_remove.append(tag_name_to_id[tag_name]) + tag_names_to_remove.append(tag_name) if not tag_ids_to_remove: return @@ -614,10 +802,51 @@ class SearchGUI: if result and int(result[0]) == 0: # Only delete single linkage type cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id)) + # Track that these tags were removed + tags_removed.update(tag_names_to_remove) + refresh_tag_list() + def update_search_results(): + """Update the search results to reflect tag changes without database access.""" + if not tags_added and not tags_removed: + return # No changes to apply + + # Get photo paths for the affected photos from selected_photos + affected_photo_paths = set(self.selected_photos.keys()) + + # Update cache for affected photos + for photo_path in affected_photo_paths: + if photo_path in self.photo_tags_cache: + # Update cached tags based on changes + current_tags = set(self.photo_tags_cache[photo_path]) + # Add new tags + current_tags.update(tags_added) + # Remove deleted tags + current_tags.difference_update(tags_removed) + # Update cache with sorted list + self.photo_tags_cache[photo_path] = sorted(list(current_tags)) + + # Update each affected row in the search results + for item in tree.get_children(): + vals = tree.item(item, "values") + if len(vals) >= 6: + photo_path = vals[4] # Photo path is at index 4 + if photo_path in affected_photo_paths: + # Get current tags for this photo from cache + current_tags = get_photo_tags_for_display(photo_path) + # Update the tags column (index 1) + new_vals = list(vals) + new_vals[1] = current_tags + tree.item(item, values=new_vals) + + def close_dialog(): + """Close dialog and update search results if needed.""" + update_search_results() + popup.destroy() + ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8)) - ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT) + ttk.Button(bottom_frame, text="Close", command=close_dialog).pack(side=tk.RIGHT) refresh_tag_list() # Click handling on icon columns @@ -630,14 +859,33 @@ class SearchGUI: if not row_id or not col_id: return vals = tree.item(row_id, "values") - if not vals or len(vals) < 5: + if not vals or len(vals) < 6: return - path = vals[3] - if col_id == "#2": + + # Determine column offsets based on search type + is_tag_search = (search_type_var.get() == self.SEARCH_TYPES[2]) + if is_tag_search: + # Tag search: person column is hidden, so all columns shift left by 1 + open_dir_col = "#2" # open_dir is now column 2 + face_col = "#3" # open_photo is now column 3 + path_col = "#4" # path is now column 4 + select_col = "#5" # select is now column 5 + path_index = 4 # path is at index 4 in values array + else: + # Name search: all columns visible + open_dir_col = "#3" # open_dir is column 3 + face_col = "#4" # open_photo is column 4 + path_col = "#5" # path is column 5 + select_col = "#6" # select is column 6 + path_index = 4 # path is at index 4 in values array + + path = vals[path_index] # Photo path + if col_id == open_dir_col: # Open directory column open_dir(path) - elif col_id == "#3": # Tag icon column - show_photo_tags(path) - elif col_id == "#4": # Photo path column - clickable to open photo + elif col_id == face_col: # Face icon column + # No popup needed, just tooltip + pass + elif col_id == path_col: # Photo path column - clickable to open photo try: if os.name == "nt": os.startfile(path) # type: ignore[attr-defined] @@ -649,7 +897,7 @@ class SearchGUI: subprocess.run(["xdg-open", path], check=False) except Exception: messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root) - elif col_id == "#5": # Checkbox column + elif col_id == select_col: # Checkbox column toggle_photo_selection(row_id, vals) # Tooltip for icon cells @@ -683,19 +931,45 @@ class SearchGUI: return col_id = tree.identify_column(event.x) row_id = tree.identify_row(event.y) - 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") + + # Determine column offsets based on search type + is_tag_search = (search_type_var.get() == self.SEARCH_TYPES[2]) + if is_tag_search: + # Tag search: person column is hidden, so all columns shift left by 1 + tags_col = "#1" # tags is now column 1 + open_dir_col = "#2" # open_dir is now column 2 + face_col = "#3" # open_photo is now column 3 + path_col = "#4" # path is now column 4 + path_index = 4 # path is at index 4 in values array + else: + # Name search: all columns visible + tags_col = "#2" # tags is column 2 + open_dir_col = "#3" # open_dir is column 3 + face_col = "#4" # open_photo is column 4 + path_col = "#5" # path is column 5 + path_index = 4 # path is at index 4 in values array + + if col_id == tags_col: # Tags column + tree.config(cursor="") # Show tags tooltip if row_id: vals = tree.item(row_id, "values") - if len(vals) >= 4: - path = vals[3] - tags_text = get_photo_tags_tooltip(path) - show_tooltip(tree, event.x_root, event.y_root, tags_text) - elif col_id == "#4": + if len(vals) >= 2: + tags_text = vals[1] # Tags are at index 1 + show_tooltip(tree, event.x_root, event.y_root, f"Tags: {tags_text}") + elif col_id == open_dir_col: # Open directory column + tree.config(cursor="hand2") + show_tooltip(tree, event.x_root, event.y_root, "Open file location") + elif col_id == face_col: # Face icon column + tree.config(cursor="hand2") + # Show people tooltip + if row_id: + vals = tree.item(row_id, "values") + if len(vals) >= 5: + path = vals[path_index] + people_text = get_photo_people_tooltip(path) + show_tooltip(tree, event.x_root, event.y_root, people_text) + elif col_id == path_col: # Photo path column tree.config(cursor="hand2") show_tooltip(tree, event.x_root, event.y_root, "Open photo") else: @@ -710,6 +984,8 @@ class SearchGUI: # Enter key in name field triggers search name_entry.bind("", lambda e: do_search()) + # Enter key in tag field triggers search + tag_entry.bind("", lambda e: do_search()) # Show and center root.update_idletasks() diff --git a/search_stats.py b/search_stats.py index f234085..1b85171 100644 --- a/search_stats.py +++ b/search_stats.py @@ -191,10 +191,69 @@ class SearchStats: return [] def search_photos_by_tags(self, tags: List[str], match_all: bool = False) -> List[Tuple]: - """Search photos by tags""" - # This would need to be implemented in the database module - # For now, return empty list - return [] + """Search photos by tags + + Args: + tags: List of tag names to search for + match_all: If True, photos must have ALL tags. If False, photos with ANY tag. + + Returns: + List of tuples: (photo_path, tag_info) + """ + if not tags: + return [] + + # Get tag IDs for the provided tag names + tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings() + tag_ids = [] + + for tag_name in tags: + if tag_name in tag_name_to_id: + tag_ids.append(tag_name_to_id[tag_name]) + + if not tag_ids: + return [] + + results = [] + try: + with self.db.get_db_connection() as conn: + cursor = conn.cursor() + + if match_all: + # Photos that have ALL specified tags + placeholders = ",".join(["?"] * len(tag_ids)) + cursor.execute(f''' + SELECT p.path, GROUP_CONCAT(t.tag_name, ', ') as tag_names + FROM photos p + JOIN phototaglinkage ptl ON p.id = ptl.photo_id + JOIN tags t ON ptl.tag_id = t.id + WHERE ptl.tag_id IN ({placeholders}) + GROUP BY p.id, p.path + HAVING COUNT(DISTINCT ptl.tag_id) = ? + ORDER BY p.path + ''', tuple(tag_ids) + (len(tag_ids),)) + else: + # Photos that have ANY of the specified tags + placeholders = ",".join(["?"] * len(tag_ids)) + cursor.execute(f''' + SELECT DISTINCT p.path, GROUP_CONCAT(t.tag_name, ', ') as tag_names + FROM photos p + JOIN phototaglinkage ptl ON p.id = ptl.photo_id + JOIN tags t ON ptl.tag_id = t.id + WHERE ptl.tag_id IN ({placeholders}) + GROUP BY p.id, p.path + ORDER BY p.path + ''', tuple(tag_ids)) + + for row in cursor.fetchall(): + if row and row[0]: + results.append((row[0], row[1] or "")) + + except Exception as e: + if self.verbose > 0: + print(f"Error searching photos by tags: {e}") + + return results def search_photos_by_people(self, people: List[str]) -> List[Tuple]: """Search photos by people"""