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.
This commit is contained in:
tanyar09 2025-10-08 14:03:41 -04:00
parent d92f5750b7
commit 40ffc0692e
5 changed files with 156 additions and 116 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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: