From 40ffc0692e1508ffa8e6539a60c3535cbe4f498d Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Wed, 8 Oct 2025 14:03:41 -0400 Subject: [PATCH] Enhance database and GUI for case-insensitive tag management This commit updates the DatabaseManager to support case-insensitive tag lookups and additions, ensuring consistent tag handling. The SearchGUI and TagManagerGUI have been modified to reflect these changes, allowing for improved user experience when managing tags. Additionally, the search logic in SearchStats and TagManagement has been adjusted for case-insensitive tag ID retrieval, enhancing overall functionality and reliability in tag management across the application. --- database.py | 23 +++++--- search_gui.py | 131 ++++++++++++++++++++++----------------------- search_stats.py | 8 +-- tag_management.py | 6 ++- tag_manager_gui.py | 104 ++++++++++++++++++++++------------- 5 files changed, 156 insertions(+), 116 deletions(-) diff --git a/database.py b/database.py index ff3e400..7876575 100644 --- a/database.py +++ b/database.py @@ -144,16 +144,17 @@ class DatabaseManager: print(f"✅ Database initialized: {self.db_path}") def load_tag_mappings(self) -> Tuple[Dict[int, str], Dict[str, int]]: - """Load tag name to ID and ID to name mappings from database""" + """Load tag name to ID and ID to name mappings from database (case-insensitive)""" with self.get_db_connection() as conn: cursor = conn.cursor() - cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name') + cursor.execute('SELECT id, tag_name FROM tags ORDER BY LOWER(tag_name)') tag_id_to_name = {} tag_name_to_id = {} for row in cursor.fetchall(): tag_id, tag_name = row tag_id_to_name[tag_id] = tag_name - tag_name_to_id[tag_name] = tag_id + # Use lowercase for case-insensitive lookups + tag_name_to_id[tag_name.lower()] = tag_id return tag_id_to_name, tag_name_to_id def get_existing_tag_ids_for_photo(self, photo_id: int) -> List[int]: @@ -258,13 +259,23 @@ class DatabaseManager: return result[0] if result else None def add_tag(self, tag_name: str) -> int: - """Add a tag to the database and return its ID""" + """Add a tag to the database and return its ID (case-insensitive)""" + # Normalize tag name to lowercase for consistency + normalized_tag_name = tag_name.lower().strip() + with self.get_db_connection() as conn: cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) + # Check if tag already exists (case-insensitive) + cursor.execute('SELECT id FROM tags WHERE LOWER(tag_name) = ?', (normalized_tag_name,)) + existing = cursor.fetchone() + if existing: + return existing[0] + + # Insert new tag with original case + cursor.execute('INSERT INTO tags (tag_name) VALUES (?)', (tag_name.strip(),)) # Get the tag ID - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) + cursor.execute('SELECT id FROM tags WHERE LOWER(tag_name) = ?', (normalized_tag_name,)) result = cursor.fetchone() return result[0] if result else None diff --git a/search_gui.py b/search_gui.py index 30d02af..7149437 100644 --- a/search_gui.py +++ b/search_gui.py @@ -104,20 +104,20 @@ 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", "tags", "open_dir", "open_photo", "path", "select") + columns = ("select", "person", "tags", "open_dir", "open_photo", "path") tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse") + tree.heading("select", text="☑") 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("path", text="Photo path", command=lambda: sort_treeview("path")) - tree.heading("select", text="☑") + tree.column("select", width=50, anchor="center") 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=400, anchor="w") - tree.column("select", width=50, anchor="center") tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0)) # Buttons @@ -198,7 +198,7 @@ class SearchGUI: 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") + tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path") 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)) @@ -209,7 +209,7 @@ class SearchGUI: 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") + tree["displaycolumns"] = ("select", "tags", "open_dir", "open_photo", "path") else: planned_label.pack(anchor="w") name_entry.configure(state="disabled") @@ -220,7 +220,7 @@ class SearchGUI: 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") + tree["displaycolumns"] = ("select", "person", "tags", "open_dir", "open_photo", "path") def clear_results(): for i in tree.get_children(): @@ -243,13 +243,13 @@ class SearchGUI: # 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, "☐")) + 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, "☐")) + tree.insert("", tk.END, values=("☐", full_name, photo_tags, "📁", "👤", p)) # Sort by appropriate column by default when results are first loaded if rows and self.sort_column is None: @@ -321,13 +321,13 @@ class SearchGUI: """Toggle checkbox selection for a photo.""" if len(vals) < 6: return - path = vals[4] # Photo path is now in column 4 - current_state = vals[5] # Checkbox is now in column 5 + current_state = vals[0] # Checkbox is now in column 0 (first) + path = vals[5] # Photo path is now in column 5 (last) if current_state == "☐": # Select photo new_state = "☑" self.selected_photos[path] = { - 'person': vals[0], + 'person': vals[1], # Person is now in column 1 'path': path } else: @@ -338,7 +338,7 @@ class SearchGUI: # Update the treeview new_vals = list(vals) - new_vals[5] = new_state + new_vals[0] = new_state tree.item(row_id, values=new_vals) def tag_selected_photos(): @@ -375,9 +375,9 @@ class SearchGUI: # Update all checkboxes to unselected state for item in tree.get_children(): vals = tree.item(item, "values") - if len(vals) >= 6 and vals[5] == "☑": + if len(vals) >= 6 and vals[0] == "☑": new_vals = list(vals) - new_vals[5] = "☐" + new_vals[0] = "☐" tree.item(item, values=new_vals) @@ -644,28 +644,25 @@ class SearchGUI: if not tag_name: return - # Resolve or create tag id - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] + # Resolve or create tag id (case-insensitive) + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in tag_name_to_id: + tag_id = tag_name_to_id[normalized_tag_name] else: - # Create new tag in database - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - result = cursor.fetchone() - if result: - tag_id = result[0] - tag_name_to_id[tag_name] = tag_id - tag_id_to_name[tag_id] = tag_name - if tag_name not in existing_tags: - existing_tags.append(tag_name) - existing_tags.sort() - # Update the combobox values to include the new tag - combo['values'] = existing_tags - else: - messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup) - return + # Create new tag in database using the database method + tag_id = self.db.add_tag(tag_name) + if tag_id: + # Update mappings + tag_name_to_id[normalized_tag_name] = tag_id + tag_id_to_name[tag_id] = tag_name + if tag_name not in existing_tags: + existing_tags.append(tag_name) + existing_tags.sort() + # Update the combobox values to include the new tag + combo['values'] = existing_tags + else: + messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup) + return # Add tag to all selected photos with single linkage type (0) affected = 0 @@ -776,7 +773,7 @@ class SearchGUI: # Display tag name with status information type_label = 'single' if linkage_type == 0 else 'bulk' photo_count = common_tag_data[tag_id]['photo_count'] - status_text = f" (saved {type_label}, on {photo_count}/{len(photo_ids)} photos)" + status_text = f" (saved {type_label})" status_color = "black" if can_select else "gray" ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT) @@ -831,13 +828,13 @@ class SearchGUI: 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 + photo_path = vals[5] # Photo path is at index 5 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) + # Update the tags column (index 2) new_vals = list(vals) - new_vals[1] = current_tags + new_vals[2] = current_tags tree.item(item, values=new_vals) def close_dialog(): @@ -865,19 +862,19 @@ class SearchGUI: # 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 + # Tag search: person column is hidden, select is first + select_col = "#1" # select is now column 1 + open_dir_col = "#3" # open_dir is now column 3 + face_col = "#4" # open_photo is now column 4 + path_col = "#5" # path is now column 5 + path_index = 5 # path is at index 5 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 + # Name search: all columns visible, select is first + select_col = "#1" # select is now column 1 + open_dir_col = "#4" # open_dir is column 4 + face_col = "#5" # open_photo is column 5 + path_col = "#6" # path is column 6 + path_index = 5 # path is at index 5 in values array path = vals[path_index] # Photo path if col_id == open_dir_col: # Open directory column @@ -935,27 +932,27 @@ class SearchGUI: # 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 + # Tag search: person column is hidden, select is first + tags_col = "#2" # tags is now column 2 + open_dir_col = "#3" # open_dir is now column 3 + face_col = "#4" # open_photo is now column 4 + path_col = "#5" # path is now column 5 + path_index = 5 # path is at index 5 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 + # Name search: all columns visible, select is first + tags_col = "#3" # tags is column 3 + open_dir_col = "#4" # open_dir is column 4 + face_col = "#5" # open_photo is column 5 + path_col = "#6" # path is column 6 + path_index = 5 # path is at index 5 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) >= 2: - tags_text = vals[1] # Tags are at index 1 + if len(vals) >= 3: + tags_text = vals[2] # Tags are at index 2 (after select and person) 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") @@ -989,8 +986,8 @@ class SearchGUI: # Show and center root.update_idletasks() - # Widened to ensure the checkbox column is visible by default - self.gui_core.center_window(root, 900, 520) + # Widened to ensure all columns are visible by default + self.gui_core.center_window(root, 1000, 520) root.deiconify() root.mainloop() return 0 diff --git a/search_stats.py b/search_stats.py index 1b85171..8af0662 100644 --- a/search_stats.py +++ b/search_stats.py @@ -203,13 +203,15 @@ class SearchStats: if not tags: return [] - # Get tag IDs for the provided tag names + # Get tag IDs for the provided tag names (case-insensitive) 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]) + # Convert to lowercase for case-insensitive lookup + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in tag_name_to_id: + tag_ids.append(tag_name_to_id[normalized_tag_name]) if not tag_ids: return [] diff --git a/tag_management.py b/tag_management.py index 57c2cdf..29c8e00 100644 --- a/tag_management.py +++ b/tag_management.py @@ -206,8 +206,10 @@ class TagManager: tag_ids = [] for tag_name in tags: - if tag_name in tag_name_to_id: - tag_ids.append(tag_name_to_id[tag_name]) + # Convert to lowercase for case-insensitive lookup + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in tag_name_to_id: + tag_ids.append(tag_name_to_id[normalized_tag_name]) if not tag_ids: return [] diff --git a/tag_manager_gui.py b/tag_manager_gui.py index ccf85a0..6f32a35 100644 --- a/tag_manager_gui.py +++ b/tag_manager_gui.py @@ -178,14 +178,13 @@ class TagManagerGUI: if not tag_name: return try: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - conn.commit() - new_tag_var.set("") - refresh_tag_list() - load_existing_tags() - switch_view_mode(view_mode_var.get()) + # Use database method for case-insensitive tag creation + tag_id = self.db.add_tag(tag_name) + if tag_id: + new_tag_var.set("") + refresh_tag_list() + load_existing_tags() + switch_view_mode(view_mode_var.get()) except Exception as e: messagebox.showerror("Error", f"Failed to add tag: {e}") @@ -502,6 +501,28 @@ class TagManagerGUI: ] } + def get_people_names_for_photo(photo_id: int) -> str: + """Get people names for a photo as a formatted string for tooltip""" + nonlocal people_names_cache + people_names = people_names_cache.get(photo_id, []) + if not people_names: + return "No people identified" + + # Remove commas from names (convert "Last, First" to "Last First") + formatted_names = [] + for name in people_names: + if ', ' in name: + # Convert "Last, First" to "Last First" + formatted_name = name.replace(', ', ' ') + else: + formatted_name = name + formatted_names.append(formatted_name) + + if len(formatted_names) <= 5: + return f"People: {', '.join(formatted_names)}" + else: + return f"People: {', '.join(formatted_names[:5])}... (+{len(formatted_names)-5} more)" + def show_people_names_popup(photo_id: int, photo_filename: str): """Show a popup window with the names of people identified in this photo""" nonlocal people_names_cache @@ -658,19 +679,21 @@ class TagManagerGUI: if not tag_name: return # Resolve or create tag id - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] + # Case-insensitive tag lookup + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in tag_name_to_id: + tag_id = tag_name_to_id[normalized_tag_name] else: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - tag_id = cursor.fetchone()[0] - tag_name_to_id[tag_name] = tag_id + # Create new tag using database method + tag_id = self.db.add_tag(tag_name) + if tag_id: + tag_name_to_id[normalized_tag_name] = tag_id tag_id_to_name[tag_id] = tag_name if tag_name not in existing_tags: existing_tags.append(tag_name) existing_tags.sort() + else: + return # Failed to create tag affected = 0 for photo in folder_info.get('photos', []): @@ -895,15 +918,15 @@ class TagManagerGUI: tag_name = tag_var.get().strip() if not tag_name: return - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] + # Case-insensitive tag lookup + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in tag_name_to_id: + tag_id = tag_name_to_id[normalized_tag_name] else: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - tag_id = cursor.fetchone()[0] - tag_name_to_id[tag_name] = tag_id + # Create new tag using database method + tag_id = self.db.add_tag(tag_name) + if tag_id: + tag_name_to_id[normalized_tag_name] = tag_id tag_id_to_name[tag_id] = tag_name if tag_name not in existing_tags: existing_tags.append(tag_name) @@ -1106,19 +1129,21 @@ class TagManagerGUI: tag_name = tag_var.get().strip() if not tag_name: return - if tag_name in tag_name_to_id: - tag_id = tag_name_to_id[tag_name] + # Case-insensitive tag lookup + normalized_tag_name = tag_name.lower().strip() + if normalized_tag_name in tag_name_to_id: + tag_id = tag_name_to_id[normalized_tag_name] else: - with self.db.get_db_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,)) - cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,)) - tag_id = cursor.fetchone()[0] - tag_name_to_id[tag_name] = tag_id + # Create new tag using database method + tag_id = self.db.add_tag(tag_name) + if tag_id: + tag_name_to_id[normalized_tag_name] = tag_id tag_id_to_name[tag_id] = tag_name if tag_name not in existing_tags: existing_tags.append(tag_name) existing_tags.sort() + else: + return # Failed to create tag saved_types = get_saved_tag_types_for_photo(photo_id) existing_tag_ids = list(saved_types.keys()) pending_tag_ids = pending_tag_changes.get(photo_id, []) @@ -1324,9 +1349,10 @@ class TagManagerGUI: elif key == 'faces' and photo['face_count'] > 0: lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') lbl.grid(row=0, column=i, padx=5, sticky=tk.W) - lbl.bind("", lambda e, pid=photo['id'], fname=photo['filename']: show_people_names_popup(pid, fname)) + # Show people names in tooltip on hover instead of popup on click try: - _ToolTip(lbl, "Click to see people in this photo") + people_tooltip_text = get_people_names_for_photo(photo['id']) + _ToolTip(lbl, people_tooltip_text) except Exception: pass else: @@ -1409,9 +1435,10 @@ class TagManagerGUI: elif key == 'faces' and photo['face_count'] > 0: lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - lbl.bind("", lambda e, pid=photo['id'], fname=photo['filename']: show_people_names_popup(pid, fname)) + # Show people names in tooltip on hover instead of popup on click try: - _ToolTip(lbl, "Click to see people in this photo") + people_tooltip_text = get_people_names_for_photo(photo['id']) + _ToolTip(lbl, people_tooltip_text) except Exception: pass else: @@ -1468,9 +1495,10 @@ class TagManagerGUI: elif key == 'faces' and photo['face_count'] > 0: lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2", wraplength=col['width'], anchor='w', justify='left') lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W) - lbl.bind("", lambda e, pid=photo['id'], fname=photo['filename']: show_people_names_popup(pid, fname)) + # Show people names in tooltip on hover instead of popup on click try: - _ToolTip(lbl, "Click to see people in this photo") + people_tooltip_text = get_people_names_for_photo(photo['id']) + _ToolTip(lbl, people_tooltip_text) except Exception: pass else: