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: